import json
import pprint
import csv
import random
import copy
import io
import datetime
from datetime import timedelta
from typing import Any
from dal import autocomplete
from django.db.models.base import Model as Model
from django.db.models.query import QuerySet
from django.db.models import Prefetch
from django.db.models import Q
from django.db import transaction
from django.shortcuts import render, get_object_or_404, redirect
from django import forms
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.html import escape
from django.views.decorators.http import require_POST
from django.views.decorators.http import require_http_methods
from django.template.loader import render_to_string
from django.db.models import Count, Max
from django.template.defaultfilters import filesizeformat

# 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 import messages
from django.core.exceptions import (
    FieldError,
    ObjectDoesNotExist,
    PermissionDenied,
    ViewDoesNotExist,
)

from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.views.generic import View
from django.views.generic.detail import DetailView
import pydicom
from pydicom.uid import generate_uid
from generic.mixins import SuperuserRequiredMixin, UserConfigurablePaginationMixin
from django.contrib.admin.views.decorators import staff_member_required
from django.core.paginator import Paginator

from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
from django.views.generic import ListView
from django_filters.views import FilterView
from django_tables2 import SingleTableMixin
from .filters import ResourceFilter
from .tables import ResourceTable
from django.views.decorators.csrf import csrf_exempt

from django.urls import reverse_lazy, reverse
from django.urls.exceptions import NoReverseMatch

from django.http import Http404, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse
from django.contrib.contenttypes.models import ContentType
from django.forms.widgets import HiddenInput
from django.forms import modelformset_factory
import zipfile
import os
from django.core.files.base import ContentFile
from django.core.exceptions import PermissionDenied

from generic.models import CidUser, CidUserExam, CimarCase, Site, get_next_cid
from atlas.helpers import get_cases_available_to_user
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD

from .forms import (
    AddCollectionToCaseForm,
    CaseAuthorForm,
    CaseCollectionAuthorForm,
    CaseCollectionCaseFormSet,
    CaseCollectionForm,
    CaseCollectionUpdateCaseForm,
    CaseDetailForm,
    CaseQuestionForm,
    MoveSelectedSeriesForm,
    CaseDisplaySetForm,
    CaseForm,
    CaseResourceFormSet,
    CaseUpdateSeriesForm,
    CidQuestionAnswerForm,
    CidReportAnswerForm,
    CidReportAnswerMarkForm,
    ConditionAutocompleteForm,
    ConditionForm,
    ConditionRelationshipForm,
    ExamGroupsForm,
    FindingForm,
    JsonAnswerForm,
    # ModelPriorCaseForm,
    PriorCaseForm,
    QuestionSchemaForm,
    ResourceForm,
    CaseReviewMessageForm,
    SelfReviewForm,
    SeriesAuthorForm,
    SeriesForm,
    SeriesImageFormSet,
    SeriesFormSet,
    SeriesFindingForm,
    CaseDifferentialFormSet,
    StructureForm,
    SubspecialtyForm,
    PresentationForm,
    PathologicalProcessForm,
    ProcedureForm,
    UserQuestionAnswerForm,
    UserReportAnswerForm,
)
from survey.views import check_survey_interception
from generic.models import UserHiddenItem
from .models import (
    Case,
    CaseCollection,
    CaseDetail,
    CaseDisplaySet,
    CasePrior,
    CaseResource,
    CaseReviewMessage,
    CidReportAnswer,
    Condition,
    Differential,
    PathologicalProcess,
    Presentation,
    QuestionSchema,
    Resource,
    SelfReview,
    Series,
    Examination,
    Finding,
    SeriesDetail,
    Structure,
    Subspecialty,
    SeriesFinding,
    SeriesImage,
    UncategorisedDicom,
    UserReportAnswer,
    PrerequisiteRequired,
    APIToken,
    _build_declared_series_groups,
)
from .models import Procedure
from .models import NormalCase
from .tables import (
    CaseCollectionTable,
    CaseTable,
    ConditionTable,
    FindingTable,
    PathologicalProcessTable,
    PresentationTable,
    QuestionSchemaTable,
    SeriesTable,
    StructureTable,
    SubspecialtyTable,
)
from .filters import (
    CaseCollectionFilter,
    CaseFilter,
    ConditionFilter,
    FindingFilter,
    PathologicalProcessFilter,
    PresentationFilter,
    QuestionSchemaFilter,
    SeriesFilter,
    StructureFilter,
    SubspecialtyFilter,
    NormalCaseFilter,
)


def _parse_study_date(value):
    if not value:
        return None
    try:
        parsed_value = str(value)
        for date_format in ("%Y%m%d", "%Y-%m-%d"):
            try:
                return datetime.datetime.strptime(parsed_value, date_format).date()
            except ValueError:
                continue
        return None
    except (TypeError, ValueError):
        return None


def _extract_study_date_from_dicoms(dicoms):
    for dicom in dicoms.iterator():
        tags = dicom.basic_dicom_tags or {}
        for key in ("StudyDate", "AcquisitionDate", "SeriesDate"):
            study_date = _parse_study_date(tags.get(key))
            if study_date:
                return study_date
    return None


def _format_relative_date_offset(days_delta):
    if days_delta is None:
        return None

    if days_delta == 0:
        return {"text": "Day 0", "unit": "day", "value": 0}

    sign_prefix = "+" if days_delta > 0 else "-"
    absolute_days = abs(days_delta)

    if absolute_days >= 365:
        value = round(absolute_days / 365.25, 1)
        unit = "year"
    elif absolute_days >= 30:
        value = round(absolute_days / 30.4375, 1)
        unit = "month"
    else:
        value = absolute_days
        unit = "day"

    if isinstance(value, float) and value.is_integer():
        value = int(value)

    suffix = "s" if value != 1 else ""
    return {
        "text": f"{sign_prefix}{value} {unit}{suffix}",
        "unit": unit,
        "value": value,
    }


from .tasks import (
    push_case_to_cimar_task,
    series_downsample_task,
    series_reconstruct_task,
)

try:
    from django_tasks import TaskResultStatus
except ImportError:
    from enum import Enum

    class TaskResultStatus(Enum):
        READY = "READY"
        RUNNING = "RUNNING"
        FAILED = "FAILED"
        SUCCESSFUL = "SUCCESSFUL"


try:
    from django_tasks_db.models import DBTaskResult
except ImportError:
    DBTaskResult = None

from django_tables2 import SingleTableView, SingleTableMixin
from django_filters.views import FilterView

# from autocomplete import HTMXAutoComplete


from .decorators import (
    user_is_author_or_atlas_editor,
    user_has_case_view_access,
    user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access,
    user_is_atlas_editor,
    user_is_author_or_atlas_series_checker,
    user_is_atlas_marker,
    user_is_collection_author_or_atlas_editor,
)

from collections import defaultdict
import json
import statistics
import plotly.express as px

from django.core.cache import cache
from django.conf import settings

from helpers.images import compare_dicom_datasets, image_as_base64, print_dicom

import logging
from copy import deepcopy
from django.forms.models import model_to_dict

from generic.views import (
    AuthorRequiredMixin,
    ExamCloneMixin,
    ExamGroupsUpdateBase,
    ExamViews,
    SeriesImagesZipViewBase,
)
from reversion.views import RevisionMixin
import reversion


import os
import difflib

from helpers.cimar import CimarAPI

from loguru import logger


@login_required
def series_truncate_htmx(request, series_id, start, end):
    """HTMX endpoint to truncate a series' images to the given inclusive
    start/end bounds (zero-based). Returns an HTML fragment with the result.

    This endpoint only performs truncation for backward compatibility.
    """
    series = get_object_or_404(Series, pk=series_id)

    if not series.check_user_can_edit(request.user):
        return HttpResponse('<div class="alert alert-danger">Permission denied</div>')

    images_removed = []

    for n, image in enumerate(series.get_images()):
        if n >= int(start) and n <= int(end):
            continue

        image.removed = True
        image.image = None
        image.save()
        images_removed.append(image.pk)

    series.modified = Series.SeriesModifiedChocies.TR
    series.save(update_fields=["modified"])

    # Recalculate stored series size
    try:
        series.get_total_image_size(refresh=True)
    except Exception:
        # ignore errors when recalculating
        pass

    # Render a simple message fragment for HTMX swap
    html = render_to_string(
        "atlas/partials/truncate_result.html",
        {
            "images_removed": images_removed,
            "images_downsampled": [],
            "series": series,
            "remove_empty_dicoms": False,
            "downsample_pixels": False,
        },
        request=request,
    )
    return HttpResponse(html)


def _read_series_image_dataset(series_image):
    if not series_image.image:
        return None

    try:
        series_image.image.open("rb")
        series_image.image.seek(0)
        return pydicom.dcmread(series_image.image, force=True)
    except Exception as exc:
        logger.warning("Failed to read DICOM for image {}: {}", series_image.pk, exc)
        return None


def _is_empty_dicom(ds):
    try:
        px = ds.pixel_array
        return bool((px == 0).all())
    except Exception:
        return False


def _resize_array_nearest(arr, target_rows, target_cols):
    import numpy as np

    rows, cols = arr.shape[0], arr.shape[1]
    row_idx = np.clip(
        np.round(np.linspace(0, rows - 1, target_rows)).astype(int), 0, rows - 1
    )
    col_idx = np.clip(
        np.round(np.linspace(0, cols - 1, target_cols)).astype(int), 0, cols - 1
    )
    return arr[np.ix_(row_idx, col_idx)]


def _stamp_derived_dataset(
    ds,
    *,
    series_instance_uid=None,
    source_series_instance_uid=None,
    series_description=None,
    derivation_description=None,
    image_type=None,
    regenerate_sop_instance_uid=True,
    stamp_series_datetime=True,
):
    now = timezone.now()
    date_str = now.strftime("%Y%m%d")
    time_str = now.strftime("%H%M%S.%f")[:13]
    source_sop_instance_uid = getattr(ds, "SOPInstanceUID", None)
    source_sop_class_uid = getattr(ds, "SOPClassUID", None)

    if regenerate_sop_instance_uid:
        ds.SOPInstanceUID = generate_uid()
    if series_instance_uid:
        ds.SeriesInstanceUID = series_instance_uid
    if series_description:
        ds.SeriesDescription = series_description

    ds.ImageType = image_type or ["DERIVED", "SECONDARY"]

    if derivation_description:
        ds.DerivationDescription = derivation_description

    if source_series_instance_uid:
        series_ref = pydicom.dataset.Dataset()
        series_ref.SeriesInstanceUID = str(source_series_instance_uid)
        ds.ReferencedSeriesSequence = pydicom.sequence.Sequence([series_ref])

    if source_sop_instance_uid:
        source_ref = pydicom.dataset.Dataset()
        if source_sop_class_uid:
            source_ref.ReferencedSOPClassUID = str(source_sop_class_uid)
        source_ref.ReferencedSOPInstanceUID = str(source_sop_instance_uid)
        ds.SourceImageSequence = pydicom.sequence.Sequence([source_ref])

    ds.ContentDate = date_str
    ds.ContentTime = time_str
    ds.InstanceCreationDate = date_str
    ds.InstanceCreationTime = time_str

    if stamp_series_datetime:
        ds.SeriesDate = date_str
        ds.SeriesTime = time_str

    return ds


def _downsample_dicom_dataset(
    ds,
    reduction_pct,
    *,
    series_instance_uid=None,
    source_series_instance_uid=None,
    series_description=None,
):
    if reduction_pct <= 0 or reduction_pct >= 100:
        raise ValueError("Downsample percentage must be between 1 and 99")

    factor = (100 - reduction_pct) / 100.0
    px = ds.pixel_array

    if px.ndim != 2:
        raise ValueError(
            "Only 2D DICOM slices are supported for this downsample operation"
        )

    target_rows = max(1, int(round(px.shape[0] * factor)))
    target_cols = max(1, int(round(px.shape[1] * factor)))
    resized = _resize_array_nearest(px, target_rows, target_cols)

    derivation_description = (
        f"Downsampled by {reduction_pct}% using nearest-neighbour resize"
    )
    _stamp_derived_dataset(
        ds,
        series_instance_uid=series_instance_uid,
        source_series_instance_uid=source_series_instance_uid,
        series_description=series_description,
        derivation_description=derivation_description,
        image_type=["DERIVED", "SECONDARY"],
    )

    if hasattr(ds, "PixelSpacing") and ds.PixelSpacing and len(ds.PixelSpacing) >= 2:
        row_spacing = _safe_float(ds.PixelSpacing[0], 1.0)
        col_spacing = _safe_float(ds.PixelSpacing[1], 1.0)
        ds.PixelSpacing = [float(row_spacing / factor), float(col_spacing / factor)]

    if (
        hasattr(ds, "ImagerPixelSpacing")
        and ds.ImagerPixelSpacing
        and len(ds.ImagerPixelSpacing) >= 2
    ):
        row_spacing = _safe_float(ds.ImagerPixelSpacing[0], 1.0)
        col_spacing = _safe_float(ds.ImagerPixelSpacing[1], 1.0)
        ds.ImagerPixelSpacing = [
            float(row_spacing / factor),
            float(col_spacing / factor),
        ]

    if (
        hasattr(ds, "NominalScannedPixelSpacing")
        and ds.NominalScannedPixelSpacing
        and len(ds.NominalScannedPixelSpacing) >= 2
    ):
        row_spacing = _safe_float(ds.NominalScannedPixelSpacing[0], 1.0)
        col_spacing = _safe_float(ds.NominalScannedPixelSpacing[1], 1.0)
        ds.NominalScannedPixelSpacing = [
            float(row_spacing / factor),
            float(col_spacing / factor),
        ]

    ds.Rows = int(target_rows)
    ds.Columns = int(target_cols)
    ds.PixelData = resized.tobytes()
    ds.pop("SmallestImagePixelValue", None)
    ds.pop("LargestImagePixelValue", None)
    ds.pop("PixelAspectRatio", None)
    return ds


def _create_series_derivative(source_series, description_suffix):
    derived = Series.objects.get(pk=source_series.pk)
    derived.pk = None
    derived.id = None
    derived.series_instance_uid = generate_uid()
    derived.source_series_instance_uid = source_series.series_instance_uid
    derived.description = (
        f"{source_series.description or 'Series'} [{description_suffix}]"
    )
    derived.modified = Series.SeriesModifiedChocies.RE
    derived.save()
    derived.case.set(source_series.case.all())
    derived.author.set(source_series.author.all())
    return derived


def _series_image_urls_json(series):
    image_url_array_and_count = series.get_image_url_array_and_count()
    try:
        urls = json.loads(image_url_array_and_count[0])
    except Exception:
        urls = []
    return urls


def _safe_float(value, default=None):
    try:
        if value is None:
            return default
        return float(value)
    except Exception:
        return default


def _infer_native_spacings(dicom_items):
    """Infer native (row, col, z) spacing in mm from a list of (image, ds, arr)."""
    first_ds = dicom_items[0][1]

    row_spacing = 1.0
    col_spacing = 1.0
    if (
        hasattr(first_ds, "PixelSpacing")
        and first_ds.PixelSpacing
        and len(first_ds.PixelSpacing) >= 2
    ):
        row_spacing = _safe_float(first_ds.PixelSpacing[0], 1.0)
        col_spacing = _safe_float(first_ds.PixelSpacing[1], 1.0)

    z_spacing = _safe_float(getattr(first_ds, "SpacingBetweenSlices", None), None)
    if z_spacing is None:
        z_spacing = _safe_float(getattr(first_ds, "SliceThickness", None), None)

    if z_spacing is None and len(dicom_items) > 1:
        try:
            p0 = dicom_items[0][1].ImagePositionPatient
            p1 = dicom_items[1][1].ImagePositionPatient
            if p0 and p1 and len(p0) == 3 and len(p1) == 3:
                import numpy as np

                z_spacing = float(
                    np.linalg.norm(
                        np.array(p1, dtype=float) - np.array(p0, dtype=float)
                    )
                )
        except Exception:
            z_spacing = None

    if z_spacing is None or z_spacing <= 0:
        z_spacing = 1.0

    return float(row_spacing), float(col_spacing), float(z_spacing)


def _extract_recon_geometry(dicom_items):
    """Build sorted geometry context for reconstruction from (image, ds, arr) items."""
    import numpy as np

    row_spacing, col_spacing, native_z_spacing = _infer_native_spacings(dicom_items)
    first_ds = dicom_items[0][1]

    row_dir = np.array([1.0, 0.0, 0.0], dtype=float)
    col_dir = np.array([0.0, 1.0, 0.0], dtype=float)

    iop = getattr(first_ds, "ImageOrientationPatient", None)
    if iop and len(iop) >= 6:
        try:
            row_dir = np.array(
                [float(iop[0]), float(iop[1]), float(iop[2])], dtype=float
            )
            col_dir = np.array(
                [float(iop[3]), float(iop[4]), float(iop[5])], dtype=float
            )
        except Exception:
            row_dir = np.array([1.0, 0.0, 0.0], dtype=float)
            col_dir = np.array([0.0, 1.0, 0.0], dtype=float)

    if np.linalg.norm(row_dir) == 0 or np.linalg.norm(col_dir) == 0:
        row_dir = np.array([1.0, 0.0, 0.0], dtype=float)
        col_dir = np.array([0.0, 1.0, 0.0], dtype=float)

    row_dir = row_dir / np.linalg.norm(row_dir)
    col_dir = col_dir / np.linalg.norm(col_dir)

    normal_dir = np.cross(row_dir, col_dir)
    if np.linalg.norm(normal_dir) == 0:
        normal_dir = np.array([0.0, 0.0, 1.0], dtype=float)
    normal_dir = normal_dir / np.linalg.norm(normal_dir)

    decorated = []
    for idx, item in enumerate(dicom_items):
        ds = item[1]
        ipp = None
        try:
            ipp_attr = getattr(ds, "ImagePositionPatient", None)
            if ipp_attr and len(ipp_attr) >= 3:
                ipp = np.array(
                    [float(ipp_attr[0]), float(ipp_attr[1]), float(ipp_attr[2])],
                    dtype=float,
                )
        except Exception:
            ipp = None

        if ipp is None:
            ipp = np.array([0.0, 0.0, idx * native_z_spacing], dtype=float)

        proj = float(np.dot(ipp, normal_dir))
        decorated.append((proj, ipp, item))

    decorated.sort(key=lambda x: x[0])
    sorted_items = [d[2] for d in decorated]
    sorted_ipps = [d[1] for d in decorated]
    sorted_proj = np.array([d[0] for d in decorated], dtype=float)

    if len(sorted_proj) > 1:
        diffs = np.diff(sorted_proj)
        valid_diffs = np.abs(diffs[np.abs(diffs) > 1e-6])
        if valid_diffs.size > 0:
            native_z_spacing = float(np.median(np.abs(valid_diffs)))

    if native_z_spacing <= 0:
        native_z_spacing = 1.0

    origin_ipp = (
        sorted_ipps[0] if sorted_ipps else np.array([0.0, 0.0, 0.0], dtype=float)
    )
    source_positions_mm = (
        sorted_proj - sorted_proj[0]
        if len(sorted_proj) > 0
        else np.array([0.0], dtype=float)
    )

    return {
        "sorted_items": sorted_items,
        "source_positions_mm": source_positions_mm,
        "origin_ipp": origin_ipp,
        "row_dir": row_dir,
        "col_dir": col_dir,
        "normal_dir": normal_dir,
        "row_spacing": float(row_spacing),
        "col_spacing": float(col_spacing),
        "native_z_spacing": float(native_z_spacing),
    }


def _aggregate_volume_along_z(
    volume, source_positions_mm, target_spacing, slab_thickness, mode
):
    """Aggregate a z-volume into slabbed slices using spacing/thickness in mm."""
    import numpy as np

    if target_spacing <= 0:
        raise ValueError("Target spacing must be positive")
    if slab_thickness <= 0:
        raise ValueError("Slab thickness must be positive")

    source_positions_mm = np.asarray(source_positions_mm, dtype=float)
    if source_positions_mm.size != volume.shape[0]:
        raise ValueError("Source position count must match volume depth")

    start_pos = float(source_positions_mm[0])
    end_pos = float(source_positions_mm[-1])
    centers = np.arange(
        start_pos, end_pos + (target_spacing * 0.5), target_spacing, dtype=float
    )
    if centers.size == 0:
        centers = np.array([start_pos], dtype=float)

    half = slab_thickness / 2.0
    slabs = []
    for center in centers:
        mask = np.abs(source_positions_mm - center) <= (half + 1e-6)
        if not mask.any():
            nearest = int(np.argmin(np.abs(source_positions_mm - center)))
            slab = volume[nearest : nearest + 1, :, :]
        else:
            slab = volume[mask, :, :]

        if mode == "max":
            out = np.max(slab, axis=0)
        elif mode == "min":
            out = np.min(slab, axis=0)
        else:
            out = np.mean(slab, axis=0)
        slabs.append(out)

    out_volume = np.stack(slabs, axis=0)

    if np.issubdtype(volume.dtype, np.integer):
        info = np.iinfo(volume.dtype)
        out_volume = np.clip(np.rint(out_volume), info.min, info.max).astype(
            volume.dtype
        )
    else:
        out_volume = out_volume.astype(volume.dtype, copy=False)

    return out_volume, centers


@login_required
@require_http_methods(["POST"])
def series_optimize_htmx(request, series_id):
    """Run disk optimization operations for a series.

    Scope: Series editors only.

    Supported operations:
    - remove_empty
    - downsample
    - downsample_preview
    - reconstruct
    - truncate
    """
    series = get_object_or_404(Series, pk=series_id)

    if not series.check_user_can_edit(request.user):
        return HttpResponse(
            '<div class="alert alert-danger mb-0">Permission denied</div>'
        )

    operation = request.POST.get("operation", "").strip()
    start = int(request.POST.get("start", "1") or 1) - 1
    end = (
        int(
            request.POST.get("end", str(series.get_image_count()))
            or series.get_image_count()
        )
        - 1
    )

    if end < start:
        return HttpResponse(
            '<div class="alert alert-danger mb-0">Start must be less than or equal to end.</div>'
        )

    images = list(series.get_images())
    bounded_images = [
        img for idx, img in enumerate(images) if idx >= start and idx <= end
    ]
    full_series_images = images

    if operation == "truncate":
        removed = []
        for idx, image in enumerate(images):
            if idx < start or idx > end:
                image.removed = True
                image.image = None
                image.save()
                removed.append(image.pk)

        series.modified = Series.SeriesModifiedChocies.TR
        series.save(update_fields=["modified"])
        try:
            series.get_total_image_size(refresh=True)
        except Exception:
            pass

        new_total = int(series.get_image_count())
        payload = f'<div id="truncate-range-payload" data-start="1" data-end="{new_total}" data-total="{new_total}"></div>'
        return HttpResponse(
            (
                '<div class="alert alert-success mb-2">'
                f"Truncate complete. Removed <strong>{len(removed)}</strong> images. "
                f"Updated working range to <strong>1-{new_total}</strong>."
                "</div>"
                f"{payload}"
            )
        )

    if operation == "remove_empty":
        removed = []
        for image in bounded_images:
            ds = _read_series_image_dataset(image)
            if ds is None:
                continue
            if _is_empty_dicom(ds):
                image.removed = True
                image.image = None
                image.save()
                removed.append(image.pk)

        if removed:
            series.modified = Series.SeriesModifiedChocies.TR
            series.save(update_fields=["modified"])
            try:
                series.get_total_image_size(refresh=True)
            except Exception:
                pass

        return HttpResponse(
            f'<div class="alert alert-success mb-0">Empty DICOM removal complete. Removed <strong>{len(removed)}</strong> empty images.</div>'
        )

    if operation in ("downsample", "downsample_preview"):
        downsample_pct = int(request.POST.get("downsample_pct", "50") or 50)

        if operation == "downsample_preview":
            preview_series = _create_series_derivative(
                series, f"Downsample preview {downsample_pct}%"
            )
            created = 0

            for image in full_series_images:
                ds = _read_series_image_dataset(image)
                if ds is None:
                    continue

                try:
                    ds_out = _downsample_dicom_dataset(
                        ds,
                        downsample_pct,
                        series_instance_uid=preview_series.series_instance_uid,
                        source_series_instance_uid=series.series_instance_uid,
                        series_description=preview_series.description,
                    )
                except Exception as exc:
                    logger.warning(
                        "Downsample preview failed for image {}: {}", image.pk, exc
                    )
                    continue

                out_io = io.BytesIO()
                ds_out.save_as(out_io, write_like_original=False)
                out_io.seek(0)

                new_image = SeriesImage(
                    series=preview_series,
                    position=image.position,
                    upload_filename=image.upload_filename,
                )
                new_image.image.save(
                    f"preview_ds_{downsample_pct}_{image.pk}.dcm",
                    ContentFile(out_io.getvalue()),
                    save=False,
                )
                new_image.save()
                created += 1

            original_urls = _series_image_urls_json(series)
            preview_urls = _series_image_urls_json(preview_series)
            named_stacks = json.dumps(
                {
                    "Original": original_urls,
                    f"Preview {downsample_pct}%": preview_urls,
                }
            )
            escaped_named_stacks = escape(named_stacks)

            return HttpResponse(
                (
                    '<div class="alert alert-info mb-2">'
                    f"Downsample preview series created with <strong>{created}</strong> images. "
                    f'<a class="alert-link" target="_blank" href="{preview_series.get_absolute_url()}">Open preview series</a>.'
                    "</div>"
                    f'<div id="downsample-compare-payload" data-named-stacks="{escaped_named_stacks}"></div>'
                )
            )

        task_result = series_downsample_task.enqueue(
            series_id=series.pk,
            user_id=request.user.pk,
            downsample_pct=downsample_pct,
        )

        return HttpResponse(
            (
                '<div class="alert alert-info mb-2">'
                "Downsample queued. This runs in the background to avoid request timeout."
                "</div>"
                f'<div id="downsample-task-status" hx-get="{reverse("atlas:series_downsample_status", kwargs={"series_id": series.pk, "task_id": task_result.id})}" '
                'hx-trigger="load, every 2s" hx-swap="outerHTML">'
                '<div class="d-flex align-items-center gap-2"><span class="spinner-border spinner-border-sm text-primary" role="status"></span>'
                '<span class="small text-muted">Starting downsample task...</span></div></div>'
            )
        )

    if operation == "reconstruct":
        recon_planes = request.POST.getlist("recon_planes")
        if not recon_planes:
            return HttpResponse(
                '<div class="alert alert-danger mb-0">Select at least one reconstruction plane.</div>'
            )

        slice_thickness = request.POST.get("recon_slice_thickness", "")
        slice_spacing = request.POST.get("recon_slice_spacing", "")
        recon_thickness_mode = (
            (request.POST.get("recon_thickness_mode", "mean") or "mean").strip().lower()
        )

        if recon_thickness_mode not in ("mean", "max", "min"):
            return HttpResponse(
                '<div class="alert alert-danger mb-0">Invalid thickness merge mode.</div>'
            )

        try:
            slice_thickness_val = float(slice_thickness) if slice_thickness else None
            slice_spacing_val = float(slice_spacing) if slice_spacing else None
        except ValueError:
            return HttpResponse(
                '<div class="alert alert-danger mb-0">Slice thickness and spacing must be numeric values.</div>'
            )

        if slice_thickness_val is not None and slice_thickness_val <= 0:
            return HttpResponse(
                '<div class="alert alert-danger mb-0">Slice thickness must be greater than 0.</div>'
            )
        if slice_spacing_val is not None and slice_spacing_val <= 0:
            return HttpResponse(
                '<div class="alert alert-danger mb-0">Slice spacing must be greater than 0.</div>'
            )
        task_result = series_reconstruct_task.enqueue(
            series_id=series.pk,
            user_id=request.user.pk,
            recon_planes=recon_planes,
            slice_thickness_val=slice_thickness_val,
            slice_spacing_val=slice_spacing_val,
            recon_thickness_mode=recon_thickness_mode,
        )

        return HttpResponse(
            (
                '<div class="alert alert-info mb-2">'
                "Reconstruction queued. This runs in the background to avoid request timeout."
                "</div>"
                f'<div id="recon-task-status" hx-get="{reverse("atlas:series_reconstruct_status", kwargs={"series_id": series.pk, "task_id": task_result.id})}" '
                'hx-trigger="load, every 2s" hx-swap="outerHTML">'
                '<div class="d-flex align-items-center gap-2"><span class="spinner-border spinner-border-sm text-primary" role="status"></span>'
                '<span class="small text-muted">Starting reconstruction task...</span></div></div>'
                f'<div id="recon-task-payload" data-task-id="{escape(str(task_result.id))}" data-status-url="{escape(reverse("atlas:series_reconstruct_status", kwargs={"series_id": series.pk, "task_id": task_result.id}))}"></div>'
            )
        )

    return HttpResponse(
        '<div class="alert alert-danger mb-0">Unknown optimize operation.</div>'
    )


@login_required
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
def series_reconstruct_advanced(request, series_id):
    """Advanced reconstruction planning page using DV3D MPR/crosshair workflow.

    Scope: users with access to the source series.
    Functionality: mounts DV3D in an MPR-focused layout, allows manual planning,
    and submits reconstruction requests while preserving the existing series tools flow.
    """
    series = get_object_or_404(Series, pk=series_id)

    if not series.check_user_can_edit(request.user):
        return HttpResponse("Permission denied", status=403)

    image_urls = [
        f"{REMOTE_URL}{img.image.url}"
        for img in series.get_images()
        if getattr(img, "image", None)
    ]
    image_ids = [f"wadouri:{url}" for url in image_urls]

    context = {
        "series": series,
        "image_ids_json": json.dumps(image_ids),
    }
    return render(request, "atlas/series_reconstruct_advanced.html", context)


@login_required
def series_reconstruct_status_htmx(request, series_id, task_id):
    """HTMX endpoint returning progress for a queued series reconstruction task."""
    series = get_object_or_404(Series, pk=series_id)
    target_id = (
        request.GET.get("target_id") or "recon-task-status"
    ).strip() or "recon-task-status"
    target_id = escape(target_id)

    if not series.check_user_can_edit(request.user):
        return HttpResponse(
            f'<div id="{target_id}" class="alert alert-danger mb-0">Permission denied</div>'
        )

    try:
        task_result = series_reconstruct_task.get_result(task_id)
    except NotImplementedError:
        return HttpResponse(
            f'<div id="{target_id}" class="alert alert-warning mb-0">Task backend does not support result polling. Configure a backend with result retrieval support.</div>'
        )
    except Exception:
        return HttpResponse(
            f'<div id="{target_id}" class="alert alert-danger mb-0">Task result not found.</div>'
        )

    task_result.refresh()
    state = task_result.status

    poll_url = reverse(
        "atlas:series_reconstruct_status",
        kwargs={"series_id": series.pk, "task_id": task_id},
    )
    progress = cache.get(f"series_reconstruct_progress:{task_id}") or {}
    current = int(progress.get("current", 0) or 0)
    total = int(progress.get("total", 0) or 0)
    message = escape(str(progress.get("message", "Reconstruction is running...")))
    pct = int((current / total) * 100) if total > 0 else 0

    if state in (TaskResultStatus.READY, TaskResultStatus.RUNNING):
        return HttpResponse(
            (
                f'<div id="{target_id}" hx-get="{poll_url}?target_id={target_id}" hx-trigger="every 2s" hx-swap="outerHTML">'
                f'<div class="small text-muted mb-1">{message}</div>'
                '<div class="progress" style="height: 10px;">'
                f'<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: {pct}%" '
                f'aria-valuenow="{pct}" aria-valuemin="0" aria-valuemax="100"></div>'
                "</div>"
                f'<div class="small text-muted mt-1">{current}/{total} slices processed</div>'
                "</div>"
            )
        )

    if state == TaskResultStatus.SUCCESSFUL:
        try:
            payload = (
                task_result.return_value
                if isinstance(task_result.return_value, dict)
                else {}
            )
        except ValueError:
            payload = {}
        created_series = payload.get("created_series", [])

        if created_series:
            links = " ".join(
                [
                    f'<a class="alert-link me-2" target="_blank" href="{escape(item.get("url", "#"))}">{escape(item.get("description", str(item.get("id", "series"))))}</a>'
                    for item in created_series
                ]
            )
            return HttpResponse(
                (
                    f'<div id="{target_id}" class="alert alert-success mb-0">'
                    f"Reconstruction complete. Created <strong>{len(created_series)}</strong> series: {links}"
                    "</div>"
                )
            )

        return HttpResponse(
            f'<div id="{target_id}" class="alert alert-warning mb-0">Reconstruction finished but no output series were created.</div>'
        )

    if state == TaskResultStatus.FAILED:
        err = "Unknown failure"
        if task_result.errors:
            err = escape(task_result.errors[-1].traceback.splitlines()[-1])
        return HttpResponse(
            f'<div id="{target_id}" class="alert alert-danger mb-0">Reconstruction failed: {err}</div>'
        )

    return HttpResponse(
        f'<div id="{target_id}" class="alert alert-secondary mb-0">Task state: {escape(str(state))}</div>'
    )


@login_required
def series_downsample_status_htmx(request, series_id, task_id):
    """HTMX endpoint returning progress for a queued series downsample task."""
    series = get_object_or_404(Series, pk=series_id)
    target_id = (
        request.GET.get("target_id") or "downsample-task-status"
    ).strip() or "downsample-task-status"
    target_id = escape(target_id)

    if not series.check_user_can_edit(request.user):
        return HttpResponse(
            f'<div id="{target_id}" class="alert alert-danger mb-0">Permission denied</div>'
        )

    try:
        task_result = series_downsample_task.get_result(task_id)
    except NotImplementedError:
        return HttpResponse(
            f'<div id="{target_id}" class="alert alert-warning mb-0">Task backend does not support result polling. Configure a backend with result retrieval support.</div>'
        )
    except Exception:
        return HttpResponse(
            f'<div id="{target_id}" class="alert alert-danger mb-0">Task result not found.</div>'
        )

    task_result.refresh()
    state = task_result.status

    poll_url = reverse(
        "atlas:series_downsample_status",
        kwargs={"series_id": series.pk, "task_id": task_id},
    )
    progress = cache.get(f"series_downsample_progress:{task_id}") or {}
    current = int(progress.get("current", 0) or 0)
    total = int(progress.get("total", 0) or 0)
    message = escape(str(progress.get("message", "Downsample is running...")))
    pct = int((current / total) * 100) if total > 0 else 0

    if state in (TaskResultStatus.READY, TaskResultStatus.RUNNING):
        return HttpResponse(
            (
                f'<div id="{target_id}" hx-get="{poll_url}?target_id={target_id}" hx-trigger="every 2s" hx-swap="outerHTML">'
                f'<div class="small text-muted mb-1">{message}</div>'
                '<div class="progress" style="height: 10px;">'
                f'<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: {pct}%" '
                f'aria-valuenow="{pct}" aria-valuemin="0" aria-valuemax="100"></div>'
                "</div>"
                f'<div class="small text-muted mt-1">{current}/{total} images processed</div>'
                "</div>"
            )
        )

    if state == TaskResultStatus.SUCCESSFUL:
        payload = task_result.return_value or {}
        updated = int(payload.get("updated_images", 0) or 0)
        downsample_pct = escape(str(payload.get("downsample_pct", "")))
        return HttpResponse(
            f'<div id="{target_id}" class="alert alert-success mb-0">Downsample complete ({downsample_pct}%). Updated <strong>{updated}</strong> images while preserving original hashes.</div>'
        )

    if state == TaskResultStatus.FAILED:
        err = "Unknown failure"
        if task_result.errors:
            err = escape(task_result.errors[-1].traceback.splitlines()[-1])
        return HttpResponse(
            f'<div id="{target_id}" class="alert alert-danger mb-0">Downsample failed: {err}</div>'
        )

    return HttpResponse(
        f'<div id="{target_id}" class="alert alert-secondary mb-0">Task state: {escape(str(state))}</div>'
    )


@login_required
@user_is_atlas_editor
def task_overview(request):
    """Atlas editor task control page for inspecting and recovering queued/background tasks."""
    if DBTaskResult is None:
        return render(
            request,
            "atlas/task_overview.html",
            {
                "tasks": [],
                "counts": {"active": 0, "completed": 0, "failed": 0},
                "backend_missing": True,
            },
        )

    if request.method == "POST":
        action = (request.POST.get("task_action") or "").strip()
        selected_ids = [
            task_id for task_id in request.POST.getlist("task_ids") if task_id
        ]

        if not selected_ids:
            messages.warning(request, "Select at least one task to run an action.")
            return redirect(
                f"{reverse('atlas:task_overview')}?status={(request.GET.get('status') or 'all').strip().lower()}"
            )

        selected_qs = DBTaskResult.objects.filter(id__in=selected_ids)

        if action == "reset_failed":
            updated = selected_qs.filter(status="FAILED").update(
                status="READY",
                started_at=None,
                finished_at=None,
                worker_ids="",
                exception_class_path="",
                traceback="",
                return_value=None,
            )
            messages.success(request, f"Reset {updated} failed task(s) to READY.")
        elif action == "reset_selected":
            blocked = selected_qs.filter(status="RUNNING").count()
            updated = selected_qs.exclude(status="RUNNING").update(
                status="READY",
                started_at=None,
                finished_at=None,
                worker_ids="",
                exception_class_path="",
                traceback="",
                return_value=None,
            )
            msg = f"Reset {updated} task(s) to READY."
            if blocked:
                msg += f" Skipped {blocked} running task(s)."
            messages.success(request, msg)
        elif action == "reset_stale_running":
            stale_minutes_raw = (request.POST.get("stale_minutes") or "30").strip()
            try:
                stale_minutes = max(int(stale_minutes_raw), 1)
            except ValueError:
                stale_minutes = 30

            cutoff = timezone.now() - timedelta(minutes=stale_minutes)
            updated = selected_qs.filter(
                status="RUNNING", started_at__lt=cutoff
            ).update(
                status="READY",
                started_at=None,
                finished_at=None,
                worker_ids="",
                exception_class_path="",
                traceback="",
                return_value=None,
            )
            messages.success(
                request,
                f"Reset {updated} stale RUNNING task(s) to READY (older than {stale_minutes} minute(s)).",
            )
        elif action == "delete_completed":
            deleted, _ = selected_qs.filter(status="SUCCESSFUL").delete()
            messages.success(request, f"Deleted {deleted} completed task record(s).")
        elif action == "delete_failed":
            deleted, _ = selected_qs.filter(status="FAILED").delete()
            messages.success(request, f"Deleted {deleted} failed task record(s).")
        elif action == "delete_selected_all":
            deleted, _ = selected_qs.delete()
            messages.success(request, f"Deleted {deleted} selected task record(s).")
        else:
            messages.error(request, "Invalid task action requested.")

        status_q = (request.GET.get("status") or "all").strip().lower()
        return redirect(f"{reverse('atlas:task_overview')}?status={status_q}")

    selected_status = (request.GET.get("status") or "all").strip().lower()
    status_map = {
        "active": ["READY", "RUNNING"],
        "completed": ["SUCCESSFUL"],
        "failed": ["FAILED"],
    }

    base_qs = DBTaskResult.objects.all().order_by("-enqueued_at")
    if selected_status in status_map:
        tasks = list(base_qs.filter(status__in=status_map[selected_status])[:300])
    else:
        tasks = list(base_qs[:300])

    for task in tasks:
        generated_series = []
        payload = task.return_value

        if isinstance(payload, str):
            try:
                payload = json.loads(payload)
            except Exception:
                payload = None

        if isinstance(payload, dict):
            for item in payload.get("created_series", []) or []:
                if not isinstance(item, dict):
                    continue
                url = item.get("url")
                description = item.get("description") or str(item.get("id", "Series"))
                if url:
                    generated_series.append({"url": url, "description": description})

        task.generated_series = generated_series

    counts = {
        "active": DBTaskResult.objects.filter(status__in=["READY", "RUNNING"]).count(),
        "completed": DBTaskResult.objects.filter(status="SUCCESSFUL").count(),
        "failed": DBTaskResult.objects.filter(status="FAILED").count(),
    }

    return render(
        request,
        "atlas/task_overview.html",
        {
            "tasks": tasks,
            "counts": counts,
            "selected_status": selected_status,
            "backend_missing": False,
        },
    )


@login_required
def series_image_size_htmx(request, series_id):
    """HTMX endpoint returning the total size of images in a series.

    If `?refresh=1` is provided the size will be recalculated.
    """
    series = get_object_or_404(Series, pk=series_id)

    # Permission: reuse existing series permission check if available
    try:
        can_view = series.check_user_can_view(request.user)
    except Exception:
        # fallback to basic permission: allow authenticated users
        can_view = True

    if not can_view:
        return HttpResponse('<div class="alert alert-danger">Permission denied</div>')

    refresh = request.GET.get("refresh") in ("1", "true", "True")

    try:
        size_bytes = series.get_total_image_size(refresh=refresh)
    except Exception:
        size_bytes = 0

    human = filesizeformat(size_bytes)

    html = render_to_string(
        "atlas/partials/_series_image_size.html",
        {
            "series": series,
            "size_bytes": size_bytes,
            "size_human": human,
            "refresh": refresh,
        },
        request=request,
    )

    return HttpResponse(html)


@login_required
def case_search_partial(request):
    """Return an HTMX partial listing cases matching the query parameter `q`.

    This view returns a small list-group fragment that can be swapped into
    the index page. It respects the user's available cases via
    get_cases_available_to_user.
    """
    # NOTE (search migration):
    #
    # This view currently performs simple `__icontains` lookups across Case
    # fields and related models. For better relevance and performance you can
    # migrate this to use PostgreSQL full-text search (FTS). Recommended steps:
    #
    # 1) Enable optional Postgres extensions (if you want trigram/unaccent):
    #    - Add migrations: `TrigramExtension()` and optionally `UnaccentExtension()`
    #      from `django.contrib.postgres.operations`.
    #
    # 2) Create a tsvector expression index (recommended) or add a
    #    SearchVectorField on the Case model. Example expression index SQL:
    #      CREATE INDEX CONCURRENTLY idx_case_search ON atlas_case
    #        USING GIN (to_tsvector('english', coalesce(title,'') || ' ' ||
    #          coalesce(description,'') || ' ' || coalesce(history,'')));
    #
    #    Alternatively use a Django migration with `GinIndex` on an
    #    `SearchVectorField` or a `Func` expression.
    #
    # 3) Query using django.contrib.postgres.search utilities. Example:
    #      from django.contrib.postgres.search import (
    #          SearchVector, SearchQuery, SearchRank
    #      )
    #      sv = (SearchVector('title', weight='A') +
    #            SearchVector('description', weight='B') +
    #            SearchVector('history', weight='C'))
    #      sq = SearchQuery(q)
    #      qs = Case.objects.annotate(search=sv).filter(search=sq)
    #                .annotate(rank=SearchRank(F('search'), sq)).order_by('-rank')
    #
    # 4) For interactive/fuzzy typing consider pg_trgm (TrigramExtension)
    #    and `TrigramSimilarity` as a fallback when FTS returns few results.
    #
    # 5) Keep in mind to re-index (or recreate indexes) when you change
    #    the fields included in the tsvector. Use concurrent index creation
    #    for minimal downtime.
    #
    # If you want, I can prepare the migration, model/index changes and a
    # Django-code example to wire FTS into this view and tune weights.
    logger.debug("case_search_partial called with GET params: %s", request.GET)
    q = request.GET.get("q", "") or request.GET.get("case-search-input", "")
    q = q.strip()

    # Start with cases available to the user
    qs = get_cases_available_to_user(request.user)

    if q:
        from django.db.models import Q

        # Search across multiple Case fields and related names (conditions,
        # presentations, subspecialties, series fields and authors).
        qs = qs.filter(
            Q(title__icontains=q)
            | Q(description__icontains=q)
            | Q(history__icontains=q)
            | Q(discussion__icontains=q)
            | Q(report__icontains=q)
            | Q(condition__name__icontains=q)
            | Q(presentation__name__icontains=q)
            | Q(subspecialty__name__icontains=q)
            | Q(series__description__icontains=q)
            # Series.modality is a FK to generic.Modality; search its text field
            | Q(series__modality__modality__icontains=q)
            | Q(series__examination__examination__icontains=q)
            | Q(author__first_name__icontains=q)
            | Q(author__last_name__icontains=q)
            | Q(author__username__icontains=q)
        ).distinct()

    qs = qs.order_by("-created_date")[:20]

    html = render_to_string(
        "atlas/partials/_case_search_results.html", {"cases": qs}, request=request
    )
    return HttpResponse(html)


@login_required
def finding_search(request):
    """HTMX endpoint returning findings, optionally filtered by case and query."""
    # Allow both HTMX and standard fetch/ajax requests (don't require the HTMX header)
    q = request.GET.get("q", "").strip()
    case_id = request.GET.get("case")
    # Use SeriesFinding objects for the widget (series-scoped findings)
    qs = SeriesFinding.objects.select_related("series").prefetch_related("findings")
    if case_id:
        try:
            cid = int(case_id)
            # Series are linked to cases through SeriesDetail; filter SeriesFinding by linked series' case
            qs = qs.filter(series__seriesdetail__case__pk=cid)
        except Exception:
            pass
    if q:
        qs = qs.filter(Q(description__icontains=q) | Q(findings__name__icontains=q))
    qs = qs.order_by("-pk").distinct()[:50]
    html = render_to_string(
        "atlas/partials/_finding_search_results.html", {"findings": qs}, request=request
    )
    return HttpResponse(html)


@login_required
def displayset_search(request):
    """HTMX endpoint returning display sets, scoped to a case if provided."""
    # Allow both HTMX and standard fetch/ajax requests (don't require the HTMX header)
    q = request.GET.get("q", "").strip()
    case_id = request.GET.get("case")
    qs = CaseDisplaySet.objects.all()
    if case_id:
        try:
            cid = int(case_id)
            qs = qs.filter(case__pk=cid)
        except Exception:
            pass
    if q:
        qs = qs.filter(name__icontains=q)
    qs = qs.order_by("name")[:50]
    html = render_to_string(
        "atlas/partials/_displayset_search_results.html",
        {"displaysets": qs},
        request=request,
    )
    return HttpResponse(html)


class AuthorOrCheckerRequiredMixin(object):
    def get_object(self, *args, **kwargs):
        obj = super().get_object(*args, **kwargs)
        if (
            self.request.user.groups.filter(name="atlas_editor").exists()
            or self.request.user.is_superuser
        ):
            return obj
        if self.request.user not in obj.get_author_objects():
            raise PermissionDenied()  # or Http404
        return obj


class AtlasEditorRequiredMixin(object):
    def get_object(self, *args, **kwargs):
        obj = super().get_object(*args, **kwargs)
        if (
            self.request.user.groups.filter(name="atlas_editor").exists()
            or self.request.user.is_superuser
        ):
            return obj
        raise PermissionDenied("You must be an atlas editor to do this.")  # or Http404


class NormalCaseList(LoginRequiredMixin, FilterView):
    model = NormalCase
    template_name = "atlas/normals_list.html"
    filterset_class = NormalCaseFilter
    paginate_by = 25

    SORT_CHOICES = {
        "newest": "-added_date",
        "oldest": "added_date",
        "age_asc": "age_days",
        "age_desc": "-age_days",
        "title_asc": "case__title",
        "title_desc": "-case__title",
    }

    def get_selected_sort(self):
        sort_key = self.request.GET.get("sort", "newest")
        if sort_key not in self.SORT_CHOICES:
            return "newest"
        return sort_key

    def get_queryset(self):
        qs = NormalCase.objects.select_related("case", "examination", "modality")
        # Restrict similar to CaseFilter: non-editors only see authored or open_access
        if not self.request.user.groups.filter(name="atlas_editor").exists():
            qs = qs.filter(
                (Q(case__author__id=self.request.user.id) | Q(case__open_access=True))
            )

        order_by = self.SORT_CHOICES[self.get_selected_sort()]
        return qs.order_by(order_by)

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["selected_sort"] = self.get_selected_sort()
        context["sort_choices"] = [
            ("newest", "Newest added"),
            ("oldest", "Oldest added"),
            ("age_asc", "Age (youngest first)"),
            ("age_desc", "Age (oldest first)"),
            ("title_asc", "Title (A–Z)"),
            ("title_desc", "Title (Z–A)"),
        ]

        params = self.request.GET.copy()
        if "page" in params:
            params.pop("page")
        context["querystring_without_page"] = params.urlencode()

        params_without_sort = params.copy()
        if "sort" in params_without_sort:
            params_without_sort.pop("sort")
        context["querystring_without_page_sort"] = params_without_sort.urlencode()
        return context


@login_required
@user_has_case_view_access
def case_displaysets(request, pk):
    """Render the dedicated case display set workspace.

    This is the full-page management/viewer screen used to review all display sets
    for a case and, when permitted, add/edit/delete them inline via HTMX.

    Query params:
    - ``displayset``: highlight/select a specific display set row.
    - ``edit``: when truthy and ``displayset`` is provided, auto-open that row's
      inline edit form on page load.
    """
    case = get_object_or_404(Case, pk=pk)
    can_edit = case.check_user_can_edit(request.user)

    displayset_id = request.GET.get("displayset")
    edit = request.GET.get("edit", False)
    selected_displayset = None
    if displayset_id:
        try:
            selected_displayset = case.display_sets.get(pk=displayset_id)
        except CaseDisplaySet.DoesNotExist:
            selected_displayset = None

    return render(
        request,
        "atlas/case_displaysets.html",
        {
            "case": case,
            "edit": edit,
            "can_edit": can_edit,
            "selected_displayset": selected_displayset,
        },
    )


@login_required
def case_search_page(request):
    """Full page case search with quick filters.

    Supports GET params:
      - q: query string
      - field: one of ['all','title','description','history','author']
      - my: '1' to limit to cases authored by current user
      - open: '1' to limit to open_access cases
      - has_images: '1' to require series images
      - subspecialty: id of subspecialty to filter
      - page: pagination page
    """
    q = request.GET.get("q", "").strip()
    field = request.GET.get("field", "all")
    my_cases = request.GET.get("my", "0") == "1"
    open_access = request.GET.get("open", "0") == "1"
    has_images = request.GET.get("has_images", "0") == "1"
    subspecialty_id = request.GET.get("subspecialty")

    qs = get_cases_available_to_user(request.user)

    if my_cases:
        qs = qs.filter(author=request.user)

    if open_access:
        qs = qs.filter(open_access=True)

    if subspecialty_id:
        try:
            sid = int(subspecialty_id)
            qs = qs.filter(subspecialty__id=sid)
        except (ValueError, TypeError):
            pass

    if q:
        if field == "title":
            qs = qs.filter(title__icontains=q)
        elif field == "description":
            qs = qs.filter(description__icontains=q)
        elif field == "history":
            qs = qs.filter(history__icontains=q)
        elif field == "author":
            qs = qs.filter(
                Q(author__first_name__icontains=q)
                | Q(author__last_name__icontains=q)
                | Q(author__username__icontains=q)
            )
        else:
            qs = qs.filter(
                Q(title__icontains=q)
                | Q(description__icontains=q)
                | Q(history__icontains=q)
                | Q(discussion__icontains=q)
                | Q(report__icontains=q)
                | Q(condition__name__icontains=q)
                | Q(presentation__name__icontains=q)
                | Q(subspecialty__name__icontains=q)
                | Q(series__description__icontains=q)
                | Q(series__modality__modality__icontains=q)
                | Q(series__examination__examination__icontains=q)
                | Q(author__first_name__icontains=q)
                | Q(author__last_name__icontains=q)
                | Q(author__username__icontains=q)
            ).distinct()

    if has_images:
        qs = qs.filter(series__images__isnull=False).distinct()

    qs = qs.order_by("-created_date")

    # Paginate results
    paginator = Paginator(qs, 25)
    page_number = request.GET.get("page")
    page_obj = paginator.get_page(page_number)

    # Recent cases (authored by current user)
    recent_cases = (
        get_cases_available_to_user(request.user)
        .filter(author=request.user)
        .order_by("-created_date")[:5]
    )

    subspecialty_queryset = Subspecialty.objects.all().order_by("name")

    return render(
        request,
        "atlas/case_search_page.html",
        {
            "cases": page_obj,
            "q": q,
            "field": field,
            "my": my_cases,
            "open": open_access,
            "has_images": has_images,
            "subspecialty_queryset": subspecialty_queryset,
            "selected_subspecialty": subspecialty_id,
            "recent_cases": recent_cases,
        },
    )


@login_required
def api_tokens_page(request):
    """Simple UI to create and revoke API tokens for the logged-in user."""
    created_token = None

    if request.method == "POST":
        action = request.POST.get("action")
        if action == "create":
            name = request.POST.get("name", "")
            scopes = request.POST.get("scopes", "")
            expires_days = request.POST.get("expires_days")
            expires = None
            if expires_days:
                try:
                    expires = timezone.now() + timezone.timedelta(
                        days=int(expires_days)
                    )
                except Exception:
                    expires = None

            token, obj = APIToken.create_token(
                request.user, name=name, scopes=scopes, expires=expires
            )
            created_token = token
        elif action == "revoke":
            revoke_id = request.POST.get("revoke_id")
            try:
                t = APIToken.objects.get(pk=revoke_id, user=request.user)
                t.revoked = True
                t.save(update_fields=["revoked"])
            except APIToken.DoesNotExist:
                pass

    tokens_qs = APIToken.objects.filter(user=request.user).order_by("-created")
    tokens = [
        {
            "id": t.id,
            "name": t.name,
            "scopes": t.scopes,
            "created": t.created,
            "expires": t.expires,
            "revoked": t.revoked,
        }
        for t in tokens_qs
    ]

    return render(
        request,
        "atlas/api_tokens.html",
        {"tokens": tokens, "created_token": created_token},
    )


@login_required
@user_has_case_view_access
@require_http_methods(["GET", "POST"])
def case_displaysets_add(request, pk):
    """Create a display set for a case and return HTMX fragments.

    GET returns a blank display set form fragment.
    POST validates and creates the display set, then returns the rendered list row.
    """
    case = get_object_or_404(Case, pk=pk)
    form = CaseDisplaySetForm(request.POST or None)
    if request.method == "POST":
        if form.is_valid():
            ds = form.save(commit=False)
            ds.case = case
            ds.author = request.user
            ds.viewerstate = request.POST.get("viewerstate") or None
            ds.annotations = request.POST.get("annotations") or None
            ds.save()
            form.save_m2m()
            # HTMX: return the new display set row HTML fragment
            html = render_to_string(
                "atlas/partials/displayset_row.html", {"ds": ds, "can_edit": True}
            )
            return HttpResponse(html)
        else:
            # HTMX: return the form HTML fragment with errors
            html = render_to_string(
                "atlas/partials/displayset_form.html",
                {"form": form, "case": case},
                request=request,
            )
            return HttpResponse(html, status=400)
    else:
        # HTMX: return the blank form HTML fragment
        html = render_to_string(
            "atlas/partials/displayset_form.html",
            {"form": form, "case": case},
            request=request,
        )
        return HttpResponse(html)


@login_required
@require_http_methods(["GET", "POST"])
def case_displaysets_edit(request, pk):
    """Edit a single display set via HTMX.

    This endpoint is fragment-only and is intended to be called from the dedicated
    ``case_displaysets`` page inline editor, not linked as a standalone page.
    """
    ds = get_object_or_404(CaseDisplaySet, pk=pk)

    case = ds.case
    if not case.check_user_can_edit(request.user):
        return HttpResponse("You do not have permission to edit this display set.")
    form = CaseDisplaySetForm(request.POST or None, instance=ds)
    if request.method == "POST":
        if form.is_valid():
            ds = form.save(commit=False)
            ds.viewerstate = request.POST.get("viewerstate") or ds.viewerstate
            ds.annotations = request.POST.get("annotations") or ds.annotations
            ds.save()
            form.save_m2m()
            # Return updated row HTML
            html = render_to_string(
                "atlas/partials/displayset_row.html",
                {"ds": ds, "can_edit": True},
                request=request,
            )
            return HttpResponse(html)
        else:
            html = render_to_string(
                "atlas/partials/displayset_form.html",
                {"form": form, "case": case},
                request=request,
            )
            return HttpResponse(html, status=400)
    else:
        html = render_to_string(
            "atlas/partials/displayset_form.html",
            {"form": form, "case": case},
            request=request,
        )
        return HttpResponse(html)


@login_required
@require_http_methods(["GET"])
def case_displaysets_detail(request, pk):
    """Redirect to the case display set workspace focused on one display set.

    Used as the canonical navigation target from non-management pages.
    It redirects to ``case_displaysets`` with ``displayset=<pk>`` and forwards
    optional ``edit`` intent (for example ``?edit=1``) so the target page can
    auto-open inline editing for permitted users.
    """
    displayset = get_object_or_404(CaseDisplaySet, pk=pk)
    case = displayset.case
    edit = request.GET.get("edit")

    redirect_url = (
        f"{reverse('atlas:case_displaysets', args=[case.pk])}?displayset={pk}"
    )
    if edit:
        redirect_url = f"{redirect_url}&edit={edit}"
    return redirect(redirect_url)


@login_required
def case_displaysets_modal(request, pk):
    """Return a display set detail fragment for modal/overlay viewers.

    This supports pages that do not have the full ``case_displaysets`` workspace
    open but still need to inspect a display set in-place.
    """
    displayset = get_object_or_404(CaseDisplaySet, pk=pk)
    can_edit = False
    try:
        can_edit = displayset.case.check_user_can_edit(request.user)
    except Exception:
        can_edit = False
    return render(
        request,
        "atlas/case_displayset_modal.html",
        {"ds": displayset, "can_edit": can_edit},
    )


def displayset_preview(request, pk):
    """Return a compact display set preview fragment for lightweight popups."""
    displayset = get_object_or_404(CaseDisplaySet, pk=pk)
    return render(
        request, "atlas/partials/_displayset_preview.html", {"ds": displayset}
    )


def case_displaysets_delete(request, pk):
    """Delete a display set and return a replacement row fragment/message for HTMX."""
    try:
        displayset = CaseDisplaySet.objects.get(pk=pk)
    except CaseDisplaySet.DoesNotExist:
        raise Http404("Display set not found.")

    if not displayset.case.check_user_can_edit(request.user):
        raise PermissionDenied("You do not have permission to delete this display set.")
    displayset.delete()
    return HttpResponse(
        "<li class='list-group-item' >Display set deleted successfully."
    )


def get_case_for_case_detail(pk: int) -> Case:
    """Return a case with eager-loaded relations used by the case detail page."""
    try:
        case = (
            Case.objects.select_related()  # Add any FK fields if needed
            .prefetch_related(
                Prefetch("author", queryset=User.objects.all()),
                Prefetch(
                    "seriesdetail_set",
                    queryset=SeriesDetail.objects.select_related("series")
                    .order_by("sort_order")
                    .prefetch_related(
                        Prefetch(
                            "series",
                            queryset=Series.objects.select_related(
                                "modality", "examination", "plane", "contrast"
                            ).prefetch_related(
                                Prefetch(
                                    "findings",
                                    queryset=SeriesFinding.objects.prefetch_related(
                                        "findings", "conditions", "structures"
                                    ),
                                ),
                                Prefetch(
                                    "images",
                                    queryset=SeriesImage.objects.filter(
                                        removed=False
                                    ).order_by("position"),
                                ),
                            ),
                        ),
                    ),
                ),
                Prefetch("subspecialty", queryset=Subspecialty.objects.all()),
                Prefetch("condition", queryset=Condition.objects.all()),
                Prefetch("procedures", queryset=Procedure.objects.all()),
                Prefetch("presentation", queryset=Presentation.objects.all()),
                Prefetch(
                    "pathological_process", queryset=PathologicalProcess.objects.all()
                ),
                Prefetch("caseresource_set", queryset=CaseResource.objects.all()),
                Prefetch("casecollection_set", queryset=CaseCollection.objects.all()),
                Prefetch(
                    "display_sets",
                    queryset=CaseDisplaySet.objects.prefetch_related(
                        Prefetch("findings", queryset=Finding.objects.all()),
                        Prefetch("structures", queryset=Structure.objects.all()),
                        Prefetch("conditions", queryset=Condition.objects.all()),
                    ),
                ),
            )
            .get(pk=pk)
        )
        case.ordered_series = [sd.series for sd in case.seriesdetail_set.all()]
        return case
    except Case.DoesNotExist:
        raise Http404("Case not found.")


@login_required
@user_has_case_view_access
def case_detail(request, pk):
    """Render the Atlas case detail page with precomputed series and move targets."""
    case = get_case_for_case_detail(pk)
    can_edit = case.check_user_can_edit(request.user)
    move_series_form = MoveSelectedSeriesForm(user=request.user, source_case=case)
    ordered_series = list(getattr(case, "ordered_series", case.get_ordered_series()))

    for series in ordered_series:
        prefetched_images = getattr(series, "_prefetched_objects_cache", {}).get(
            "images"
        )
        if prefetched_images is not None:
            series.visible_image_count = len(prefetched_images)
        else:
            series.visible_image_count = series.images.filter(removed=False).count()

    move_series_recent_cases = list(
        get_cases_available_to_user(request.user)
        .exclude(pk=case.pk)
        .filter(author=request.user)
        .order_by("-created_date")
        .prefetch_related("author", "condition", "presentation", "subspecialty")[:5]
    )

    return render(
        request,
        "atlas/case_detail.html",
        {
            "case": case,
            "cimar_sid": request.user.userprofile.cimar_sid,
            "can_edit": can_edit,
            "move_series_form": move_series_form,
            "move_series_recent_cases": move_series_recent_cases,
            "ordered_series": ordered_series,
            # Provide helper lists for resource search filters
            "site_queryset": Site.objects.all(),
            "subspecialty_queryset": Subspecialty.objects.all(),
        },
    )


CASE_INLINE_FIELD_CONFIG = {
    "title": {"label": "Title", "kind": "text"},
    "description": {"label": "Description", "kind": "textarea"},
    "history": {"label": "History", "kind": "textarea"},
    "discussion": {"label": "Discussion", "kind": "textarea"},
    "report": {"label": "Report", "kind": "textarea"},
    "diagnostic_certainty": {"label": "Diagnostic certainty", "kind": "select"},
    "subspecialty": {"label": "Subspecialty", "kind": "select"},
}


def _dedupe_non_empty(values):
    out = []
    seen = set()
    for value in values:
        text = (value or "").strip()
        if not text or text in seen:
            continue
        seen.add(text)
        out.append(text)
    return out


def _get_case_inline_suggestions(case, field_name):
    condition_names = [c.name for c in case.condition.all()[:3]]
    presentation_names = [p.name for p in case.presentation.all()[:3]]
    procedure_names = [p.name for p in case.procedures.all()[:3]]

    conditions = ", ".join(condition_names)
    presentations = ", ".join(presentation_names)
    procedures = ", ".join(procedure_names)

    first_series = None
    try:
        ordered = case.get_ordered_series()
        if ordered:
            first_series = ordered[0]
    except Exception:
        first_series = None

    modality_text = ""
    examination_text = ""
    if first_series is not None:
        try:
            if first_series.modality is not None:
                modality_text = str(first_series.modality)
        except Exception:
            modality_text = ""
        try:
            if first_series.examination is not None:
                examination_text = str(first_series.examination)
        except Exception:
            examination_text = ""

    if field_name == "title":
        return _dedupe_non_empty(
            [
                conditions,
                f"{conditions} presenting with {presentations}"
                if conditions and presentations
                else "",
                presentations,
                f"{modality_text} {examination_text}"
                if modality_text or examination_text
                else "",
            ]
        )[:4]

    if field_name == "description":
        return _dedupe_non_empty(
            [
                f"{modality_text} {examination_text}".strip(),
                f"Case demonstrating {conditions}" if conditions else "",
                f"Key presentation: {presentations}" if presentations else "",
            ]
        )[:4]

    if field_name == "history":
        return _dedupe_non_empty(
            [
                f"Presentation: {presentations}." if presentations else "",
                f"Relevant background includes {procedures}." if procedures else "",
                f"Working diagnosis: {conditions}." if conditions else "",
            ]
        )[:4]

    if field_name == "discussion":
        return _dedupe_non_empty(
            [
                f"Imaging findings are most consistent with {conditions}."
                if conditions
                else "",
                f"Differential considerations include {conditions}."
                if conditions
                else "",
                "Correlation with clinical history and prior imaging is advised.",
            ]
        )[:4]

    if field_name == "report":
        return _dedupe_non_empty(
            [
                f"Findings are in keeping with {conditions}." if conditions else "",
                f"Clinical indication: {presentations}." if presentations else "",
                "No acute abnormality identified.",
            ]
        )[:4]

    return []


def _validate_case_inline_value(field_name, raw_value):
    if field_name == "diagnostic_certainty":
        try:
            parsed = int(raw_value)
        except Exception:
            return None, "Please choose a valid diagnostic certainty option."

        allowed = {choice.value for choice in Case.CertaintyChoices}
        if parsed not in allowed:
            return None, "Please choose a valid diagnostic certainty option."
        return parsed, None

    value = (raw_value or "").strip()
    if field_name == "title":
        if not value:
            return None, "Title cannot be empty."
        if len(value) > 255:
            return None, "Title must be 255 characters or fewer."

    return value, None


@login_required
@user_has_case_view_access
@require_http_methods(["GET", "POST"])
def case_inline_field(request, pk, field_name):
    case = get_case_for_case_detail(pk)

    field_cfg = CASE_INLINE_FIELD_CONFIG.get(field_name)
    if field_cfg is None:
        raise Http404("Unknown inline-edit field.")

    can_edit = case.check_user_can_edit(request.user)
    editing = request.GET.get("edit") == "1"
    show_label = request.GET.get("show_label", "1") not in ("0", "false", "False")
    error_message = ""

    # Special-case handling for many-to-many `subspecialty` field
    if field_name == "subspecialty":
        if request.method == "POST":
            if not can_edit:
                return HttpResponse(status=403)
            editing = True
            # Accept multiple values via POST list
            posted = request.POST.getlist("value")
            ids = []
            for v in posted:
                try:
                    ids.append(int(v))
                except Exception:
                    continue
            # keep only valid existing ids
            valid_ids = list(
                Subspecialty.objects.filter(pk__in=ids).values_list("pk", flat=True)
            )
            case.subspecialty.set(valid_ids)
            editing = False
            value = case.subspecialty.all()
        else:
            value = case.subspecialty.all()
            if editing and not can_edit:
                editing = False
    else:
        if request.method == "POST":
            if not can_edit:
                return HttpResponse(status=403)

            editing = True
            cleaned_value, error_message = _validate_case_inline_value(
                field_name=field_name,
                raw_value=request.POST.get("value", ""),
            )
            if error_message:
                value = request.POST.get("value", "")
            else:
                setattr(case, field_name, cleaned_value)
                case.save(update_fields=[field_name])
                editing = False
                value = getattr(case, field_name)
        else:
            value = getattr(case, field_name)
            if editing and not can_edit:
                editing = False

    if field_name == "diagnostic_certainty":
        display_value = case.get_diagnostic_certainty_display()
        select_choices = [
            (str(choice.value), str(choice.label)) for choice in Case.CertaintyChoices
        ]
        selected_values = []
        multiple = False
    elif field_name == "subspecialty":
        # For many-to-many, provide choices and selected values
        display_value = ", ".join([str(s) for s in case.subspecialty.all()])
        select_choices = [(str(s.pk), str(s.name)) for s in Subspecialty.objects.all()]
        selected_values = [str(s.pk) for s in case.subspecialty.all()]
        multiple = True
    else:
        display_value = value
        select_choices = []
        selected_values = []
        multiple = False

    endpoint_url = reverse(
        "atlas:case_inline_field", kwargs={"pk": case.pk, "field_name": field_name}
    )
    if show_label:
        endpoint_url_default = endpoint_url
        endpoint_url_edit = f"{endpoint_url}?edit=1"
    else:
        endpoint_url_default = f"{endpoint_url}?show_label=0"
        endpoint_url_edit = f"{endpoint_url}?edit=1&show_label=0"

    html = render_to_string(
        "atlas/partials/case_inline_field.html",
        {
            "case": case,
            "field_name": field_name,
            "field_label": field_cfg["label"],
            "field_kind": field_cfg["kind"],
            "value": value,
            "display_value": display_value,
            "editing": editing,
            "show_label": show_label,
            "can_edit": can_edit,
            "error_message": error_message,
            "suggestions": _get_case_inline_suggestions(case, field_name),
            "select_choices": select_choices,
            "selected_values": selected_values,
            "multiple": multiple,
            "endpoint_url_default": endpoint_url_default,
            "endpoint_url_edit": endpoint_url_edit,
            "container_id": f"case-inline-{field_name}",
        },
        request=request,
    )
    return HttpResponse(html)


@login_required
@require_POST
def toggle_case_normal(request, pk):
    """HTMX endpoint to toggle the NormalCase mark for a Case.

    - Only users in the 'atlas_editor' group or superusers may toggle.
    - On create, attempt to auto-extract age_years from DICOM metadata.
    - Returns an HTML fragment that replaces the toggle block.
    """
    case = get_case_for_case_detail(pk)

    # Permission: rely on `can_edit` check on the Case model
    can_edit = case.check_user_can_edit(request.user)
    if not can_edit:
        return HttpResponse(status=403)

    # If already marked normal, unmark (delete the NormalCase)
    try:
        normal = case.normal_case
    except Exception:
        normal = None

    if normal is not None:
        normal.delete()
        # Remove any cached relation on the case and reload so the template
        # sees the updated state (no normal_case).
        try:
            if hasattr(case, "normal_case"):
                delattr(case, "normal_case")
        except Exception:
            pass
        case = get_case_for_case_detail(pk)
    else:
        # Attempt to auto-extract age from DICOM
        age = None
        try:
            from datetime import datetime
            import re

            # Iterate series then images to find first usable DICOM
            for series in case.series.all():
                img = series.images.filter(removed=False).first()
                if not img:
                    continue
                ds = img.get_dicom_data()
                if not ds:
                    continue

                # Try PatientAge (format often like '045Y' or '45Y')
                try:
                    pa = getattr(ds, "PatientAge", None)
                except Exception:
                    pa = None

                if pa:
                    s = str(pa)
                    m = re.search(r"(\d+)", s)
                    if m:
                        age = int(m.group(1))
                        break

                # Try birthdate + studydate
                try:
                    pbd = getattr(ds, "PatientBirthDate", None)
                    sdate = (
                        getattr(ds, "StudyDate", None)
                        or getattr(ds, "AcquisitionDate", None)
                        or getattr(ds, "SeriesDate", None)
                    )
                except Exception:
                    pbd = None
                    sdate = None

                if pbd and sdate:
                    try:
                        bd = datetime.strptime(str(pbd), "%Y%m%d")
                        sd = datetime.strptime(str(sdate), "%Y%m%d")
                        years = round((sd - bd).days / 365.25)
                        age = int(years)
                        break
                    except Exception:
                        pass

        except Exception:
            age = None

        # Create NormalCase
        from .models import NormalCase

        # Convert inferred integer years -> canonical days (approx)
        if age is not None:
            age_days = NormalCase.to_days(age, NormalCase.AGE_UNIT_YEARS)
        else:
            age_days = None

        NormalCase.objects.create(case=case, age_days=age_days, added_by=request.user)

    # Render the updated fragment
    # Recompute flags for the fragment render
    is_atlas_editor = (
        request.user.is_superuser
        or request.user.groups.filter(name="atlas_editor").exists()
    )
    is_case_author = case.author.filter(id=request.user.id).exists()
    can_edit = case.check_user_can_edit(request.user)
    html = render_to_string(
        "atlas/partials/_normal_toggle.html",
        {
            "case": case,
            "user": request.user,
            "is_atlas_editor": is_atlas_editor,
            "is_case_author": is_case_author,
            "can_edit": can_edit,
        },
        request=request,
    )
    return HttpResponse(html)


@login_required
def case_normal_form(request, pk):
    """Return an HTMX modal with a NormalCaseForm prepopulated with auto-extracted values."""
    case = get_case_for_case_detail(pk)

    # Permission check: rely on `can_edit` helper on the Case model
    can_edit = case.check_user_can_edit(request.user)
    if not can_edit:
        return HttpResponse(status=403)

    # Extract defaults
    age = None
    inferred_examination = None
    inferred_modality = None
    try:
        for series in case.series.all():
            img = series.images.filter(removed=False).first()
            if not img:
                continue
            ds = img.get_dicom_data()
            if not ds:
                continue

            pa = getattr(ds, "PatientAge", None)
            if pa:
                import re

                m = re.search(r"(\d+)", str(pa))
                if m:
                    age = int(m.group(1))
                    break

            pbd = getattr(ds, "PatientBirthDate", None)
            sdate = (
                getattr(ds, "StudyDate", None)
                or getattr(ds, "AcquisitionDate", None)
                or getattr(ds, "SeriesDate", None)
            )
            if pbd and sdate:
                from datetime import datetime

                try:
                    bd = datetime.strptime(str(pbd), "%Y%m%d")
                    sd = datetime.strptime(str(sdate), "%Y%m%d")
                    years = round((sd - bd).days / 365.25)
                    age = int(years)
                    break
                except Exception:
                    pass

        # Try inferring exam/modality from first series
        first_series = case.series.first()
        if first_series is not None:
            inferred_examination = getattr(first_series, "examination", None)
            inferred_modality = getattr(first_series, "modality", None)
    except Exception:
        pass

    # Build form with initial values
    from .forms import NormalCaseForm

    # Map inferred age (years) into age_value/unit defaults. If we inferred
    # a fractional year (from birthdate), prefer months for ages < 2 years.
    from .models import NormalCase

    inferred_age_value = None
    inferred_age_unit = None
    try:
        if age is not None:
            # age currently holds integer years; for small ages prefer months
            if age < 2:
                inferred_age_value = int(age * 12)
                inferred_age_unit = NormalCase.AGE_UNIT_MONTHS
            else:
                inferred_age_value = int(age)
                inferred_age_unit = NormalCase.AGE_UNIT_YEARS
    except Exception:
        inferred_age_value = None
        inferred_age_unit = None

    form = NormalCaseForm(
        initial={
            "age_value": inferred_age_value,
            "age_unit": inferred_age_unit,
            "examination": inferred_examination,
            "modality": inferred_modality,
        }
    )

    # Log the rendered form HTML so we can verify inputs/names reach the client
    from loguru import logger

    try:
        form_html = form.as_p()
        logger.debug("case_normal_form rendered form HTML: {}", form_html)
    except Exception as e:
        logger.exception("Failed to render form.as_p(): {}", e)

    html = render_to_string(
        "atlas/partials/_normal_form.html",
        {"form": form, "case": case},
        request=request,
    )
    logger.debug("case_normal_form returning HTML length: {}", len(html))
    return HttpResponse(html)


@login_required
@require_POST
def create_case_normal(request, pk):
    case = get_case_for_case_detail(pk)

    # Permission check: rely on `can_edit` helper on the Case model
    can_edit = case.check_user_can_edit(request.user)
    if not can_edit:
        return HttpResponse(status=403)

    from .forms import NormalCaseForm
    from loguru import logger

    form = NormalCaseForm(request.POST)
    logger.debug("create_case_normal POST data: {}", dict(request.POST))
    if form.is_valid():
        logger.debug("NormalCaseForm.cleaned_data: {}", form.cleaned_data)
        nc = form.save(commit=False)
        nc.case = case
        # Save instance before modifying many-to-many relations
        nc.save()
        form.save_m2m()
        nc.author.add(request.user)

        # Ensure the case reflects the newly created NormalCase when rendering
        try:
            if hasattr(case, "normal_case"):
                delattr(case, "normal_case")
        except Exception:
            pass
        case = get_case_for_case_detail(pk)

        # Return the updated toggle fragment and close modal via inline script
        is_atlas_editor = (
            request.user.is_superuser
            or request.user.groups.filter(name="atlas_editor").exists()
        )
        is_case_author = case.author.filter(id=request.user.id).exists()
        can_edit = case.check_user_can_edit(request.user)
        toggle_html = render_to_string(
            "atlas/partials/_normal_toggle.html",
            {
                "case": case,
                "user": request.user,
                "is_atlas_editor": is_atlas_editor,
                "is_case_author": is_case_author,
                "can_edit": can_edit,
            },
            request=request,
        )
        toggle_html += "<script>var m = bootstrap.Modal.getOrCreateInstance(document.getElementById('normal-modal-{}')); if(m) m.hide();</script>".format(
            case.pk
        )
        return HttpResponse(toggle_html)
    else:
        # On form errors, re-render the modal with errors
        html = render_to_string(
            "atlas/partials/_normal_form.html",
            {"form": form, "case": case},
            request=request,
        )
        return HttpResponse(html, status=400)


@login_required
@require_http_methods(["GET", "POST"])
def case_normal_edit(request, pk):
    """Edit an existing NormalCase. Accessible to NormalCase authors and atlas editors."""
    normal = get_object_or_404(NormalCase, pk=pk)

    # Permission: allow NormalCase authors or atlas editors/superusers
    is_atlas_editor = (
        request.user.is_superuser
        or request.user.groups.filter(name="atlas_editor").exists()
    )
    is_normal_author = normal.author.filter(id=request.user.id).exists()
    if not (is_atlas_editor or is_normal_author):
        return HttpResponse(status=403)

    from .forms import NormalCaseForm

    if request.method == "POST":
        form = NormalCaseForm(request.POST, instance=normal)
        if form.is_valid():
            nc = form.save(commit=False)
            nc.save()
            form.save_m2m()

            # Ensure the case reflects any updates
            try:
                if hasattr(nc.case, "normal_case"):
                    delattr(nc.case, "normal_case")
            except Exception:
                pass

            case = get_case_for_case_detail(nc.case.pk)
            is_case_author = case.author.filter(id=request.user.id).exists()
            can_edit = case.check_user_can_edit(request.user)
            toggle_html = render_to_string(
                "atlas/partials/_normal_toggle.html",
                {
                    "case": case,
                    "user": request.user,
                    "is_atlas_editor": is_atlas_editor,
                    "is_case_author": is_case_author,
                    "can_edit": can_edit,
                },
                request=request,
            )
            toggle_html += "<script>var m = bootstrap.Modal.getOrCreateInstance(document.getElementById('normal-modal-{}')); if(m) m.hide();</script>".format(
                case.pk
            )
            return HttpResponse(toggle_html)
        else:
            html = render_to_string(
                "atlas/partials/_normal_form.html",
                {"form": form, "case": normal.case, "normal": normal},
                request=request,
            )
            return HttpResponse(html, status=400)

    # GET - render form in modal
    form = NormalCaseForm(instance=normal)
    html = render_to_string(
        "atlas/partials/_normal_form.html",
        {"form": form, "case": normal.case, "normal": normal},
        request=request,
    )
    return HttpResponse(html)


@login_required
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
def series_thumbnail_fail(request, pk):
    return series_thumbnail(request, pk=pk, fail_loudly=True)


@login_required
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
def series_thumbnail(request, pk, fail_loudly=False):
    series = get_object_or_404(Series, pk=pk)

    thumbnail, _ = series.get_thumbnail(recreate=True, fail_loudly=fail_loudly)

    return HttpResponse(thumbnail)


@login_required
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
def series_viewer(request, pk, finding_pk=None):
    series = get_object_or_404(Series, pk=pk)
    context = {
        "series": series,
    }

    return render(
        request,
        "atlas/series_viewer_test.html",
        context,
    )


@login_required
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
def series_detail(request, pk, finding_pk=None):
    series = get_object_or_404(Series, pk=pk)
    series_images_with_urls = [
        (image, f"{REMOTE_URL}{image.image.url}") for image in series.get_images()
    ]
    detected_series_groups = _build_declared_series_groups(
        series, series_images_with_urls
    )

    can_edit = series.check_user_can_edit(request.user)

    editing_finding = -1
    if finding_pk is not None:
        finding = get_object_or_404(SeriesFinding, pk=finding_pk)
        series_finding_form = SeriesFindingForm(None, instance=finding)
        editing_finding = finding.pk
    else:
        series_finding_form = SeriesFindingForm(series_id=series.id)

    context = {
        "series": series,
        "series_finding_form": series_finding_form,
        "editing_finding": editing_finding,
        "can_edit": can_edit,
        "detected_series_groups": detected_series_groups,
    }

    if can_edit:
        context["available_cases"] = Case.objects.filter(
            author__id=request.user.id
        )  # Adjust the queryset as needed

    # logging.debug(atlas.subspecialty.first().name.all())
    return render(
        request,
        "atlas/series.html",
        context,
    )


@login_required
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
def series_images_partial(request, pk):
    """Return a small fragment that lists images for a series. Intended for HTMX."""
    series = get_object_or_404(Series, pk=pk)
    logger.debug(series)
    images = series.images.filter(removed=False).order_by("pk")
    logger.debug(images)
    return render(
        request,
        "atlas/partials/series_images.html",
        {"series": series, "images": images},
    )


@login_required
# @user_is_atlas_editor
def question_schema_detail(request, pk: int):
    question_schema = get_object_or_404(QuestionSchema, pk=pk)

    example_form = question_schema.get_example_form()

    # logging.debug(atlas.subspecialty.first().name.all())
    return render(
        request,
        "atlas/question_schema_detail.html",
        {"question_schema": question_schema, "example_form": example_form},
    )


@login_required
# @user_is_atlas_editor
def question_schema_schemas(request):
    question_schemas = QuestionSchema.objects.all()

    return render(
        request,
        "atlas/question_schemas_preset.html",
        {
            "question_schemas": question_schemas,
        },
    )


@login_required
# @user_is_atlas_editor
def condition_detail(request, pk):
    condition = get_object_or_404(Condition, pk=pk)

    # form used for the merge UI
    merge_form = ConditionAutocompleteForm()

    # form used to add a synonym (can post either an existing condition via autocomplete
    # or provide a plain name in 'synonym_name')
    synonym_form = ConditionAutocompleteForm()
    relationship_form = ConditionRelationshipForm(instance=condition)

    can_merge = False

    if (
        request.user.is_superuser
        or request.user.groups.filter(name="atlas_editor").exists()
    ):
        can_merge = True

    if (
        request.method == "POST"
        and request.POST.get("action") == "update_relationships"
    ):
        if not can_merge:
            return HttpResponse("Forbidden", status=403)

        relationship_form = ConditionRelationshipForm(request.POST, instance=condition)
        if relationship_form.is_valid():
            relationship_form.save()
            if request.htmx:
                html = render_to_string(
                    "atlas/partials/_condition_relationships.html",
                    {
                        "condition": condition,
                        "relationship_form": relationship_form,
                    },
                    request=request,
                )
                return HttpResponse(html)
            return redirect("atlas:condition_detail", pk=condition.pk)

    # Handle POST to add a synonym
    if request.method == "POST" and request.POST.get("action") == "add_synonym":
        # only allow staff/editors
        if not (
            request.user.is_superuser
            or request.user.groups.filter(name="atlas_editor").exists()
        ):
            return HttpResponse("Forbidden", status=403)

        # Try autocomplete-selected condition first
        form = ConditionAutocompleteForm(request.POST)
        other = None
        if form.is_valid():
            other = form.cleaned_data.get("condition")

        # keep the form (possibly with errors) to render back in HTMX responses
        synonym_form = form

        # If no autocomplete selection, accept a free-text name
        synonym_name = request.POST.get("synonym_name")
        if not other and synonym_name:
            other, created = Condition.objects.get_or_create(name=synonym_name)
            if created:
                # created condition: treat as alias by setting canonical below
                pass

        if other and other != condition:
            # Option B: treat synonym as alias/canonical relationship.
            # Determine canonical/master for the current condition
            master = condition.canonical if condition.canonical else condition

            # If `other` belongs to a different canonical group, move its group
            # to point at our master. If `other` has no canonical, set it.
            if other.canonical:
                # move the whole other group (canonical + aliases) to our master
                group_master = other.canonical
                members = Condition.objects.filter(
                    Q(canonical=group_master) | Q(pk=group_master.pk)
                )
                for m in members:
                    if m.pk != master.pk:
                        m.canonical = master
                        m.save()
            else:
                # other is standalone; make it an alias of our master
                other.canonical = master
                other.save()

            # Master is indicated by having no `canonical` pointer. Ensure
            # it remains canonical (no save required unless changed elsewhere).

        # If this was an HTMX request return the updated synonyms partial so the
        # page can be updated without a full redirect. Otherwise perform the
        # existing redirect back to the detail page.
        if request.htmx:
            html = render_to_string(
                "atlas/partials/_synonyms_list.html",
                {
                    "condition": condition,
                    "synonym_form": synonym_form,
                    "can_merge": can_merge,
                },
                request=request,
            )
            return HttpResponse(html)

        return redirect("atlas:condition_detail", pk=condition.pk)

    # logging.debug(atlas.subspecialty.first().name.all())
    return render(
        request,
        "atlas/condition_detail.html",
        {
            "condition": condition,
            "form": merge_form,
            "synonym_form": synonym_form,
            "relationship_form": relationship_form,
            "can_merge": can_merge,
        },
    )


# class GetConditionAutocomplete(HTMXAutoComplete):
#    name = "conditions"
#    multiselect = False
#
#    class Meta:
#        model = Condition


@login_required
@user_is_atlas_editor
def condition_merge(request, pk_to_merge):
    if not request.htmx:
        raise Http404

    condition_to_merge = get_object_or_404(Condition, pk=pk_to_merge)
    # condition_to_merge_into = get_object_or_404(Condition, pk=pk_to_merge_into)

    logger.debug("condition_merge: case_set={}", condition_to_merge.case_set)
    if condition_to_merge.rcr_curriculum:
        return HttpResponse("You cannot merge rcr curriculum items")

    condition_to_merge_into = request.POST.get("conditions")

    if not condition_to_merge_into:
        return HttpResponse("You need to select a condition to merge into.")

    condition_to_merge_into = get_object_or_404(Condition, pk=condition_to_merge_into)

    if condition_to_merge == condition_to_merge_into:
        return HttpResponse("You cannot merge into the same condition.")

    # move all relevant relationships
    cases = condition_to_merge.case_set.all().values_list("id", flat=True)
    condition_to_merge_into.case_set.add(cases)
    # cases = Case.objects.filter(condition=condition_to_merge)
    # cases.update(condition=condition_to_merge_into)

    differentials = Differential.objects.filter(condition=condition_to_merge)
    differentials.update(condition=condition_to_merge_into)

    return HttpResponse("Merged")

    # conditions = Condition.objects.all()

    # return render(request, "atlas/condition_options.html", {"conditions": conditions})


@login_required
# @user_is_atlas_editor
def subspecialty_detail(request, pk):
    subspecialty = get_object_or_404(Subspecialty, pk=pk)

    # logging.debug(atlas.subspecialty.first().name.all())
    return render(
        request,
        "atlas/subspecialty_detail.html",
        {
            "subspecialty": subspecialty,
        },
    )


@login_required
# @user_is_atlas_editor
def presentation_detail(request, pk):
    presentation = get_object_or_404(Presentation, pk=pk)

    # logging.debug(atlas.subspecialty.first().name.all())
    return render(
        request,
        "atlas/presentation_detail.html",
        {
            "presentation": presentation,
        },
    )


@login_required
def procedure_detail(request, pk):
    procedure = get_object_or_404(Procedure, pk=pk)

    # Cases that have this procedure
    cases = Case.objects.filter(procedures__id=procedure.pk)

    return render(
        request,
        "atlas/procedure_detail.html",
        {
            "procedure": procedure,
            "cases": cases,
        },
    )


@login_required
# @user_is_atlas_editor
def pathological_process_detail(request, pk):
    pathological_process = get_object_or_404(PathologicalProcess, pk=pk)

    # logging.debug(atlas.subspecialty.first().name.all())
    return render(
        request,
        "atlas/pathological_process_detail.html",
        {
            "pathological_process": pathological_process,
        },
    )


@login_required
# @user_is_atlas_editor
def structure_detail(request, pk):
    structure = get_object_or_404(Structure, pk=pk)

    # logging.debug(atlas.subspecialty.first().name.all())
    return render(
        request,
        "atlas/structure_detail.html",
        {
            "structure": structure,
        },
    )


@login_required
# @user_is_atlas_editor
def finding_detail(request, pk):
    finding = get_object_or_404(Finding, pk=pk)

    # logging.debug(atlas.subspecialty.first().name.all())
    return render(
        request,
        "atlas/finding_detail.html",
        {
            "finding": finding,
        },
    )


@login_required
def resource_detail(request, pk):
    resource = get_object_or_404(Resource, pk=pk)

    return render(request, "atlas/resource_detail.html", {"resource": resource})


@login_required
def resource_view(request, pk):
    resource = get_object_or_404(Resource, pk=pk)

    return HttpResponse(resource.get_display())


@login_required
def author_detail(request, pk):
    # logging.debug(Author.objects.all())
    # author = get_object_or_404(Author, pk=pk)
    author = User.objects.get(pk=pk)

    if request.user.pk == pk:
        atlas = Case.objects.filter(author=pk)
    else:
        atlas = Case.objects.filter(author=pk, open_access=True)

    return render(
        request, "atlas/category_detail.html", {"category": author, "atlas": atlas}
    )


@user_is_author_or_atlas_editor
def author_list(request):
    authors = User.objects.all()

    return render(request, "atlas/author_list.html", {"authors": authors})


@login_required
def index(request):
    return render(request, "atlas/index.html", {})


@login_required
def user_collections(request):
    """Return collection buckets for a user (available/in-progress/finished).

    By default this uses ``request.user``. Superusers may optionally provide
    ``?user_id=<id>`` to inspect another user's collection state. HTMX requests
    return the ``atlas/partials/_user_collections.html`` fragment; normal
    requests render ``atlas/user_collections.html``.
    """

    target_user = request.user
    requested_user_id = request.GET.get("user_id")
    if requested_user_id and request.user.is_superuser:
        try:
            target_user = User.objects.get(pk=int(requested_user_id))
        except (User.DoesNotExist, ValueError, TypeError):
            target_user = request.user

    # Collections the user is explicitly allowed to take (via CaseCollection.valid_user_users)
    available_collections = target_user.user_casecollection_exams.all()
    # Collections the user has already taken (recorded in generic.CidUserExam)
    in_progress_ids = []
    finished_ids = []
    try:
        ct = ContentType.objects.get_for_model(CaseCollection)
        taken_exams = CidUserExam.objects.filter(user_user=target_user, content_type=ct)
        in_progress_ids = list(
            taken_exams.filter(completed=False)
            .values_list("object_id", flat=True)
            .distinct()
        )
        finished_ids = list(
            taken_exams.filter(completed=True)
            .values_list("object_id", flat=True)
            .distinct()
        )
    except Exception:
        # If anything goes wrong determining taken/finished exams, treat as none
        in_progress_ids = []
        finished_ids = []

    # Build querysets for the three groups
    taken_all_ids = set(in_progress_ids) | set(finished_ids)

    # Available to start: explicitly allowed collections that the user hasn't taken at all
    available_qs = available_collections.exclude(pk__in=taken_all_ids).order_by("name")

    # In-progress (taken but not completed)
    in_progress_qs = CaseCollection.objects.filter(
        pk__in=list(in_progress_ids)
    ).order_by("name")

    # Finished (completed)
    finished_qs = CaseCollection.objects.filter(pk__in=list(finished_ids)).order_by(
        "name"
    )

    show_hidden = request.GET.get("show_hidden") in ("true", "1")

    # Get content type for CaseCollection
    ct_cc = ContentType.objects.get_for_model(CaseCollection)
    hidden_ids = set(
        UserHiddenItem.objects.filter(user=target_user, content_type=ct_cc).values_list(
            "object_id", flat=True
        )
    )

    if not show_hidden:
        available_qs = available_qs.exclude(pk__in=hidden_ids)
        in_progress_qs = in_progress_qs.exclude(pk__in=hidden_ids)
        finished_qs = finished_qs.exclude(pk__in=hidden_ids)

    available_list = list(available_qs)
    in_progress_list = list(in_progress_qs)
    finished_list = list(finished_qs)

    for coll in available_list + in_progress_list + finished_list:
        coll.is_hidden = coll.pk in hidden_ids

    context = {
        "target_user": target_user,
        "available_collections": available_list,
        "in_progress_collections": in_progress_list,
        "finished_collections": finished_list,
        "show_hidden": show_hidden,
    }

    # If called via HTMX return a small fragment suitable for swapping
    # into the index page. Otherwise render the full page.
    is_htmx = (
        getattr(request, "htmx", False)
        or request.headers.get("HX-Request", "").lower() == "true"
    )
    if is_htmx:
        html = render_to_string(
            "atlas/partials/_user_collections.html", context, request=request
        )
        return HttpResponse(html)

    return render(request, "atlas/user_collections.html", context)

    # Note: keep the above full-page render for normal requests. If this
    # endpoint is called by HTMX we prefer returning a small fragment so it
    # can be swapped into the index page without duplicating layout.


@login_required
def user_related_collections(request):
    """Return up to 10 most recently modified collections where the user
    is an author or a marker. If called via HTMX return a fragment suitable
    for swapping into the index page.
    """
    user = request.user
    qs = (
        CaseCollection.objects.filter(Q(author=user) | Q(markers=user))
        .distinct()
        .order_by("-updated")[:10]
    )

    related = []
    for c in qs:
        related.append(
            {
                "collection": c,
                "is_author": c.author.filter(id=user.id).exists(),
                "is_marker": c.markers.filter(id=user.id).exists(),
            }
        )

    context = {"related_collections": related}

    is_htmx = (
        getattr(request, "htmx", False)
        or request.headers.get("HX-Request", "").lower() == "true"
    )
    if is_htmx:
        html = render_to_string(
            "atlas/partials/_user_related_collections.html", context, request=request
        )
        return HttpResponse(html)

    return render(request, "atlas/user_collections.html", context)


@login_required
def collection_options(request):
    if not request.htmx:
        return Http404

    collections = CaseCollection.objects.filter(author=request.user).order_by("name")
    html = render_to_string(
        "atlas/partials/collection_options.html",
        {"collections": collections},
        request=request,
    )
    return HttpResponse(html)


@login_required
def collection_case_jump_partial(request, pk: int):
    """Return an HTMX partial containing a jump list for cases in the collection.

    Expects optional GET params:
      - current: the current case index (0-based) to mark active
      - cid, passcode: optional candidate identifiers so links include them when present
    """
    # Allow non-HTMX callers to still get the fragment (useful for testing)
    collection = get_object_or_404(CaseCollection, pk=pk)

    casedetails = (
        CaseDetail.objects.filter(collection=collection)
        .select_related("case")
        .order_by("sort_order")
    )

    try:
        current = int(request.GET.get("current", 0) or 0)
    except Exception:
        current = 0

    cid = request.GET.get("cid")
    passcode = request.GET.get("passcode")

    try:
        cid_value = int(cid) if cid is not None else None
    except Exception:
        cid_value = None

    cid_user_exam = collection.get_or_create_cid_user_exam(
        cid=cid_value,
        user_user=request.user,
    )

    if cid_value is not None:
        answers = CidReportAnswer.objects.filter(
            cid=cid_value, question__in=casedetails
        )
    else:
        answers = UserReportAnswer.objects.filter(
            user=request.user, question__in=casedetails
        )

    answer_case_ids = {
        ans.question.case_id for ans in answers if (ans.answer or ans.json_answer)
    }
    answer_by_case_id = {ans.question.case_id: ans for ans in answers}

    latest_reviews = (
        cid_user_exam.selfreview_set.filter(case__in=[cd.case for cd in casedetails])
        .select_related("case")
        .order_by("case_id", "-review_update_date")
    )

    latest_review_by_case = {}
    for review in latest_reviews:
        if review.case_id not in latest_review_by_case:
            latest_review_by_case[review.case_id] = review

    jump_items = []
    for idx, cd in enumerate(casedetails):
        if cid is not None:
            case_url = reverse(
                "atlas:collection_case_view_take",
                kwargs={
                    "pk": collection.pk,
                    "case_number": idx,
                    "cid": cid,
                    "passcode": passcode,
                },
            )
        else:
            case_url = reverse(
                "atlas:collection_case_view_take_user",
                kwargs={"pk": collection.pk, "case_number": idx},
            )

        add_url = reverse(
            "atlas:add_self_review",
            kwargs={"user_exam_id": cid_user_exam.id, "case_id": cd.case_id},
        )

        review = latest_review_by_case.get(cd.case_id)
        timing_state = _build_case_timing_state(
            collection,
            cd,
            answer_by_case_id.get(cd.case_id),
        )
        review_view_url = None
        review_edit_url = None
        if review is not None:
            review_view_url = reverse(
                "atlas:self_review_detail",
                kwargs={
                    "user_exam_id": cid_user_exam.id,
                    "case_id": cd.case_id,
                    "pk": review.id,
                },
            )
            review_edit_url = reverse(
                "atlas:self_review_update",
                kwargs={
                    "user_exam_id": cid_user_exam.id,
                    "case_id": cd.case_id,
                    "pk": review.id,
                },
            )

        jump_items.append(
            {
                "idx": idx,
                "cd": cd,
                "case_url": case_url,
                "self_review_add_url": add_url,
                "self_review_view_url": review_view_url,
                "self_review_edit_url": review_edit_url,
                "has_answer": cd.case_id in answer_case_ids,
                "has_self_review": review is not None,
                "is_time_expired": timing_state["effective_time_limit"] is not None
                and timing_state["case_view_locked"],
            }
        )

    html = render_to_string(
        "atlas/partials/_collection_case_jump.html",
        {
            "collection": collection,
            "casedetails": casedetails,
            "jump_items": jump_items,
            "current": current,
            "cid": cid,
            "passcode": passcode,
        },
        request=request,
    )

    return HttpResponse(html)


@login_required
def remove_cases_from_collection(request):
    if not request.htmx:
        return Http404
    case_ids = request.POST.getlist("selection")
    collection_id = request.POST.get("collection_id")
    if not case_ids or not collection_id:
        return HttpResponse("No cases selected or collection ID provided.")
    collection = get_object_or_404(CaseCollection, pk=collection_id)
    if request.user not in collection.author.all():
        return HttpResponse(
            "You do not have permission to remove cases from this collection."
        )
    cases = Case.objects.filter(pk__in=case_ids)
    for case_ in cases:
        collection.cases.remove(case_)
    return HttpResponse(
        f"Removed {len(cases)} cases from collection {collection.name} (refresh to see)"
    )


@login_required
def add_cases_to_collection(request):
    if not request.htmx:
        return Http404
    case_ids = request.POST.getlist("selection")
    collection_id = request.POST.get("collection_id")
    if not case_ids or not collection_id:
        return HttpResponse("No cases selected or collection ID provided.")
    collection = get_object_or_404(CaseCollection, pk=collection_id)
    if request.user not in collection.author.all():
        return HttpResponse(
            "You do not have permission to add cases to this collection."
        )
    cases = Case.objects.filter(pk__in=case_ids)
    for case_ in cases:
        collection.cases.add(case_)
    return HttpResponse(
        f"Added {len(cases)} cases to collection {collection.name} (refresh to see)"
    )


@login_required
def set_open_access(request):
    if not request.htmx:
        return Http404

    case_ids = request.POST.getlist("selection")
    if not case_ids:
        return HttpResponse("No case ID(s) provided.")

    cases = Case.objects.filter(pk__in=case_ids)

    for case in cases:
        # This will not let give accurate permission errors if the user does not have permission to edit any of the cases
        # but it is simpler to just check the first case.
        if not case.check_user_can_edit(request.user):
            return HttpResponse(
                f"You do not have permission to set case {case.name} as open access."
            )

        case.open_access = True
        case.save()

    return HttpResponse(
        f"Cases {', '.join([case.title for case in cases])} set to open access (refresh to see changes)."
    )


@login_required
def remove_open_access(request):
    if not request.htmx:
        return Http404

    case_ids = request.POST.getlist("selection")
    if not case_ids:
        return HttpResponse("No case ID(s) provided.")

    cases = Case.objects.filter(pk__in=case_ids)

    for case in cases:
        # This will not let give accurate permission errors if the user does not have permission to edit any of the cases
        # but it is simpler to just check the first case.
        if not case.check_user_can_edit(request.user):
            return HttpResponse(
                f"You do not have permission to remove open access from case {case.name}."
            )

        case.open_access = False
        case.save()

    return HttpResponse(
        f"Cases {', '.join([case.title for case in cases])} removed from open access (refresh to see changes)."
    )


def add_case_to_collection(request, collection_id):
    if not request.htmx:
        return Http404

    collection = get_object_or_404(CaseCollection, pk=collection_id)

    if request.method == "POST":
        case = get_object_or_404(Case, pk=request.POST["case"])
        if request.user not in collection.author.all():
            return HttpResponse(
                "You do not have permission to add cases to this collection."
            )

        if case in collection.cases.all():
            return HttpResponse("Case already in collection")

        logger.debug("collection_add_case: case={}", case)
        collection.add_case(case)
        # Render the new case list item and return it so HTMX can append it
        try:
            casedetail = collection.casedetail_set.get(case=case)
        except Exception:
            return HttpResponse("Case added, but could not render item.")

        total = collection.casedetail_set.count()
        can_edit = (
            request.user.is_superuser
            or collection.author.filter(pk=request.user.pk).exists()
        )
        rendered = render_to_string(
            "atlas/partials/case_list_item.html",
            {
                "casedetail": casedetail,
                "collection": collection,
                "forloop": {"counter": total, "counter0": max(0, total - 1)},
                "can_edit": can_edit,
            },
            request=request,
        )
        # Also instruct the client to remove this case from any visible search/recent lists
        remove_script = (
            "<script>"
            "(function(){"
            "try {"
            "var pk = '%s';"
            % case.pk
            + "document.querySelectorAll('#case-search-results [data-case-pk=\"' + pk + '\"]').forEach(function(el){ el.remove(); });"
            + "document.querySelectorAll('#case-search-results input[name=\"case\"][value=\"' + pk + '\"]').forEach(function(inp){ var li = inp.closest('.list-group-item'); if (li) li.remove(); });"
            + '} catch(e) { console.error("remove added case from search error", e); }'
            "})();"
            "</script>"
        )

        return HttpResponse(rendered + remove_script)

    # if request.method == "POST":

    # don't show search results on initial load; the search partial will render only when a query exists
    cases = Case.objects.none()

    # Also compute recent cases for initial display (authored by current user and not in collection)
    recent_qs = get_cases_available_to_user(request.user).filter(author=request.user)
    recent_qs = recent_qs.exclude(pk__in=collection.cases.values_list("pk", flat=True))
    recent_cases = recent_qs.order_by("-created_date")[:5]

    return render(
        request,
        "atlas/add_case_to_collection.html",
        {
            "cases": cases,
            "collection": collection,
            "recent_cases": recent_cases,
            "q": "",
        },
    )


@login_required
def case_search(request):
    # HTMX endpoint for searching cases available to the user
    if not request.htmx:
        return Http404

    q = request.GET.get("q", "").strip()
    collection_id = request.GET.get("collection")
    show_select = str(request.GET.get("show_select", "")).lower() in (
        "1",
        "true",
        "yes",
        "on",
    )
    logger.debug(f"case_search: q={q}, collection_id={collection_id}, show_select={show_select}")
    collection = None
    if collection_id:
        try:
            collection = CaseCollection.objects.get(pk=collection_id)
        except CaseCollection.DoesNotExist:
            collection = None

    qs = get_cases_available_to_user(request.user, collection=collection)
    if q:
        qs = qs.filter(title__icontains=q)

    # Recent cases: last 5 created that are available to the user and not already in the collection
    # Restrict recent cases to those authored by the current user and not already in the collection
    recent_qs = get_cases_available_to_user(request.user).filter(author=request.user)
    if collection is not None:
        recent_qs = recent_qs.exclude(
            pk__in=collection.cases.values_list("pk", flat=True)
        )
    recent_cases = recent_qs.order_by("-created_date")[:5]

    cases = []
    if q:
        # Exclude recent cases from the main search results to avoid duplication
        recent_pks = [c.pk for c in recent_cases]

        # Build the base queryset for results, apply excludes first (before slicing)
        cases_qs = qs.exclude(pk__in=recent_pks)

        # Also exclude any cases that are already present in the collection
        if collection is not None:
            existing_pks = list(collection.cases.values_list("pk", flat=True))
            if existing_pks:
                cases_qs = cases_qs.exclude(pk__in=existing_pks)

        cases = cases_qs.order_by("title")[:30]

    return render(
        request,
        "atlas/partials/case_search_results.html",
        {
            "cases": cases,
            "collection": collection,
            "recent_cases": recent_cases,
            "q": q,
            "show_select_button": show_select,
        },
    )


@login_required
def user_uploads_series(request, series_instance_uid: str):
    # We don't restrict to teh uploading user here. Should we?
    dicoms = UncategorisedDicom.objects.filter(series_instance_uid=series_instance_uid)

    if not dicoms:
        raise Http404("Series not found")

    return render(
        request,
        "atlas/user_upload_viewer.html",
        {"dicoms": dicoms, "series_id": series_instance_uid},
    )


@login_required
def new_uploads(request):
    return render(request, "atlas/new_uploads.html", {})


@login_required
@user_passes_test(lambda u: u.is_superuser)
def all_uploads(request, case_id: int | None = None):
    return user_uploads(request, case_id, user_only=False)


@staff_member_required
def uploads_hash_search(request):
    hash_query = (request.GET.get("hash") or "").strip().lower()
    series_query = (request.GET.get("series") or "").strip()
    study_query = (request.GET.get("study") or "").strip()

    uploads_qs = UncategorisedDicom.objects.none()
    imported_qs = SeriesImage.objects.none()
    matched_series_qs = Series.objects.none()

    has_query = bool(hash_query or series_query or study_query)

    if has_query:
        uploads_filters = Q()
        imported_filters = Q()
        matched_series_filters = Q()

        if hash_query:
            uploads_filters |= Q(image_blake3_hash__icontains=hash_query)
            imported_filters |= Q(image_blake3_hash__icontains=hash_query)

        if series_query:
            uploads_filters |= Q(series_instance_uid__icontains=series_query)
            imported_filters |= Q(series__series_instance_uid__icontains=series_query)
            matched_series_filters |= Q(series_instance_uid__icontains=series_query)

        if study_query:
            uploads_filters |= Q(
                basic_dicom_tags__StudyInstanceUID__icontains=study_query
            )
            imported_filters |= Q(series__study_instance_uid__icontains=study_query)
            matched_series_filters |= Q(study_instance_uid__icontains=study_query)

        uploads_qs = (
            UncategorisedDicom.objects.filter(uploads_filters)
            .select_related("user", "series")
            .order_by("-created_date")
            .distinct()
        )

        imported_qs = (
            SeriesImage.objects.filter(imported_filters)
            .select_related("series")
            .order_by("-pk")
            .distinct()
        )

        if matched_series_filters.children:
            matched_series_qs = (
                Series.objects.filter(matched_series_filters)
                .prefetch_related("case")
                .order_by("-modified_date")
                .distinct()
            )
        else:
            matched_series_qs = Series.objects.none()

    uploads_paginator = Paginator(uploads_qs, 50)
    uploads_page_number = request.GET.get("uploads_page")
    uploads_page_obj = uploads_paginator.get_page(uploads_page_number)

    imported_paginator = Paginator(imported_qs, 50)
    imported_page_number = request.GET.get("imported_page")
    imported_page_obj = imported_paginator.get_page(imported_page_number)

    matched_series_paginator = Paginator(matched_series_qs, 50)
    matched_series_page_number = request.GET.get("series_page")
    matched_series_page_obj = matched_series_paginator.get_page(
        matched_series_page_number
    )

    return render(
        request,
        "atlas/uploads_hash_search.html",
        {
            "hash_query": hash_query,
            "series_query": series_query,
            "study_query": study_query,
            "uploads_page_obj": uploads_page_obj,
            "uploads_result_count": uploads_paginator.count,
            "imported_page_obj": imported_page_obj,
            "imported_result_count": imported_paginator.count,
            "matched_series_page_obj": matched_series_page_obj,
            "matched_series_result_count": matched_series_paginator.count,
            "series_study_scan_limit": 0,
        },
    )


@user_passes_test(lambda u: u.is_superuser)
def other_user_uploads(request, user_pk: int, case_id: int | None = None):
    return user_uploads(request, case_id, user_only=False, user_pk=user_pk)


@login_required
def user_uploads(
    request,
    case_id: int | None = None,
    user_only: bool = True,
    user_pk: int | None = None,
):
    """List uploaded (unimported) DICOM series and suggest continuation imports.

    Scope: Authenticated users (or superusers viewing other users' uploads).

    Functionality:
    - Groups uploaded series by study for easier selection.
    - Detects when at least one uploaded series from a study is already imported
      into a case.
    - Exposes per-study suggestions to import only the remaining uploaded series
      into that same case.
    """
    user = request.user
    if user_only:
        dicoms = UncategorisedDicom.objects.filter(user=request.user)
    elif user_pk is not None:
        user = get_object_or_404(User, pk=user_pk)
        dicoms = UncategorisedDicom.objects.filter(user=user)
    else:
        dicoms = UncategorisedDicom.objects.filter()

    data = defaultdict(list)
    series_hashes = defaultdict(set)
    for d in dicoms:
        tags = d.basic_dicom_tags

        if tags is None or "PatientID" not in tags:
            try:
                d.save()
                tags = d.basic_dicom_tags
            except Exception:
                pass

        if tags is None:
            continue

        series_uid = tags.get("SeriesInstanceUID")
        if not series_uid:
            continue

        data[series_uid].append((tags, d.created_date))
        if d.image_blake3_hash:
            series_hashes[series_uid].add(d.image_blake3_hash)

    series_list = []
    for series in data:
        tags, date = data[series][0]
        series_list.append((series, len(data[series]), tags, date))

    series_list.sort(key=lambda l: l[-1] or "")

    # Group series by StudyInstanceUID for better visual grouping in the template
    grouped = {}
    for series_uid, count, tags, date in series_list:
        study_uid = tags.get("StudyInstanceUID") or tags.get("StudyID") or "unknown"
        study_desc = tags.get("StudyDescription", "")
        # Normalise to a sortable string; DICOM StudyDate is "YYYYMMDD", fall
        # back to the upload's created_date converted to the same format.
        raw_study_date = tags.get("StudyDate")
        if raw_study_date:
            study_date = str(raw_study_date)
        elif hasattr(date, "strftime"):
            study_date = date.strftime("%Y%m%d")
        else:
            study_date = str(date)
        if study_uid not in grouped:
            grouped[study_uid] = {
                "description": study_desc,
                "date": study_date,
                "series": [],
                "import_suggestions": [],
                "partial_imported_uids": [],
                "matched_existing_series": [],
            }
        grouped[study_uid]["series"].append((series_uid, count, tags, date))

    # Convert to a list preserving order sorted by group date
    grouped_series = sorted(grouped.items(), key=lambda kv: kv[1].get("date") or "")

    # Build per-study suggestions where some series are already in a case but
    # additional uploaded series from the same study are still pending import.
    uploaded_series_uids = [series_uid for series_uid, _, _, _ in series_list]
    uploaded_study_uids = {
        (tags.get("StudyInstanceUID") or tags.get("StudyID") or "")
        for _, _, tags, _ in series_list
        if (tags.get("StudyInstanceUID") or tags.get("StudyID"))
    }
    uploaded_series_uid_to_study_uid = {
        series_uid: (tags.get("StudyInstanceUID") or tags.get("StudyID") or "")
        for series_uid, _, tags, _ in series_list
    }
    uploaded_uid_to_existing_series_ids: dict[str, set[int]] = defaultdict(set)
    uploaded_uid_to_case_ids: dict[str, set[int]] = defaultdict(set)
    existing_series_lookup: dict[int, Series] = {}
    case_to_study_uids = defaultdict(set)
    uploaded_hashes = {
        image_hash for hash_set in series_hashes.values() for image_hash in hash_set
    }
    hash_to_uploaded_uids = defaultdict(set)
    for uploaded_uid, hash_set in series_hashes.items():
        for image_hash in hash_set:
            hash_to_uploaded_uids[image_hash].add(uploaded_uid)

    if uploaded_series_uids:
        existing_series = Series.objects.filter(
            series_instance_uid__in=uploaded_series_uids
        ).prefetch_related("case")

        existing_study_series = Series.objects.filter(
            study_instance_uid__in=uploaded_study_uids
        ).prefetch_related("case")

        case_to_series_uids = defaultdict(set)
        case_lookup = {}

        for existing in existing_series:
            if not existing.series_instance_uid:
                continue
            existing_series_lookup[existing.pk] = existing
            uploaded_uid_to_existing_series_ids[existing.series_instance_uid].add(
                existing.pk
            )
            for case_obj in existing.case.all():
                if not case_obj.check_user_can_edit(request.user):
                    continue
                case_lookup[case_obj.pk] = case_obj
                case_to_series_uids[case_obj.pk].add(existing.series_instance_uid)
                uploaded_uid_to_case_ids[existing.series_instance_uid].add(case_obj.pk)
                if existing.study_instance_uid:
                    case_to_study_uids[case_obj.pk].add(existing.study_instance_uid)

        for existing in existing_study_series:
            if not existing.study_instance_uid:
                continue

            existing_series_lookup[existing.pk] = existing
            for (
                uploaded_uid,
                uploaded_study_uid,
            ) in uploaded_series_uid_to_study_uid.items():
                if (
                    uploaded_study_uid
                    and uploaded_study_uid == existing.study_instance_uid
                ):
                    uploaded_uid_to_existing_series_ids[uploaded_uid].add(existing.pk)

            for case_obj in existing.case.all():
                if not case_obj.check_user_can_edit(request.user):
                    continue
                case_lookup[case_obj.pk] = case_obj
                case_to_study_uids[case_obj.pk].add(existing.study_instance_uid)

        if uploaded_hashes:
            imported_hash_matches = (
                SeriesImage.objects.filter(image_blake3_hash__in=uploaded_hashes)
                .exclude(series=None)
                .select_related("series")
                .prefetch_related("series__case")
            )

            for image in imported_hash_matches:
                if image.series is None:
                    continue

                existing_series_lookup[image.series.pk] = image.series
                matched_uploaded_uids = hash_to_uploaded_uids.get(
                    image.image_blake3_hash, set()
                )
                for uploaded_uid in matched_uploaded_uids:
                    uploaded_uid_to_existing_series_ids[uploaded_uid].add(
                        image.series.pk
                    )
                    for case_obj in image.series.case.all():
                        if not case_obj.check_user_can_edit(request.user):
                            continue
                        case_lookup[case_obj.pk] = case_obj
                        case_to_series_uids[case_obj.pk].add(uploaded_uid)
                        uploaded_uid_to_case_ids[uploaded_uid].add(case_obj.pk)
                        study_uid = uploaded_series_uid_to_study_uid.get(uploaded_uid)
                        if study_uid:
                            case_to_study_uids[case_obj.pk].add(study_uid)

        for study_uid, study_data in grouped_series:
            study_uploaded_uids = {
                s_uid for s_uid, _, _, _ in study_data.get("series", [])
            }
            if not study_uploaded_uids:
                continue

            case_candidates_for_study = set()

            study_partial_imported_uids = sorted(
                s_uid
                for s_uid in study_uploaded_uids
                if uploaded_uid_to_existing_series_ids.get(s_uid)
            )
            if study_partial_imported_uids:
                study_data["partial_imported_uids"] = study_partial_imported_uids
                study_data["partial_imported_count"] = len(study_partial_imported_uids)

                matched_series_ids = set()
                for uploaded_uid in study_partial_imported_uids:
                    matched_series_ids.update(
                        uploaded_uid_to_existing_series_ids.get(uploaded_uid, set())
                    )

                study_data["matched_existing_series"] = [
                    existing_series_lookup[series_id]
                    for series_id in sorted(matched_series_ids)
                    if series_id in existing_series_lookup
                ]

                matched_case_counts = defaultdict(int)
                for uploaded_uid in study_partial_imported_uids:
                    for case_pk in uploaded_uid_to_case_ids.get(uploaded_uid, set()):
                        matched_case_counts[case_pk] += 1

                if matched_case_counts:
                    preferred_case_id, _ = max(
                        matched_case_counts.items(),
                        key=lambda item: (
                            item[1],
                            len(
                                case_to_series_uids.get(item[0], set())
                                & study_uploaded_uids
                            ),
                            -item[0],
                        ),
                    )
                    study_data["preferred_case_id"] = preferred_case_id

            related_case_ids = set(case_to_series_uids.keys()) | set(
                case_to_study_uids.keys()
            )
            for case_pk in related_case_ids:
                imported_uids = case_to_series_uids.get(case_pk, set())
                overlap = study_uploaded_uids & imported_uids
                case_study_uids = case_to_study_uids.get(case_pk, set())

                if not overlap and (not study_uid or study_uid not in case_study_uids):
                    continue

                case_candidates_for_study.add(case_pk)

                missing_uids = sorted(study_uploaded_uids - imported_uids)
                if not missing_uids:
                    continue

                study_data["import_suggestions"].append(
                    {
                        "case": case_lookup[case_pk],
                        "already_imported_count": len(overlap),
                        "missing_uids": missing_uids,
                    }
                )

            if study_data.get("preferred_case_id"):
                pass
            elif len(case_candidates_for_study) == 1:
                study_data["preferred_case_id"] = next(iter(case_candidates_for_study))
            elif study_data.get("import_suggestions"):
                sorted_suggestions = sorted(
                    study_data["import_suggestions"],
                    key=lambda s: s.get("already_imported_count", 0),
                    reverse=True,
                )
                study_data["preferred_case_id"] = sorted_suggestions[0]["case"].pk

    # Now group the processed studies by PatientID
    patient_groups = defaultdict(
        lambda: {
            "studies": [],
            "min_date": "99999999",
        }
    )

    for study_uid, study_data in grouped_series:
        patient_id = "Unknown Patient"
        if study_data["series"]:
            first_series_tags = study_data["series"][0][2]
            patient_id = first_series_tags.get("PatientID") or "Unknown Patient"

        pg = patient_groups[patient_id]
        pg["studies"].append((study_uid, study_data))
        study_date = study_data.get("date") or ""
        if study_date and study_date < pg["min_date"]:
            pg["min_date"] = study_date

    grouped_patients = sorted(
        [
            {
                "patient_id": patient_id,
                "studies": pg["studies"],
                "min_date": pg["min_date"],
            }
            for patient_id, pg in patient_groups.items()
        ],
        key=lambda x: x["min_date"] or "",
    )

    case = False
    if case_id is not None:
        case = get_object_or_404(Case, pk=case_id)

    case_series_form = CaseForm(user=request.user)
    case_series_form.fields["previous_case"].required = False
    case_series_form.fields["previous_case"].widget = HiddenInput()

    return render(
        request,
        "atlas/user_uploads.html",
        {
            "series_list": series_list,
            "grouped_series": grouped_series,
            "grouped_patients": grouped_patients,
            "uploaded_series_uids": uploaded_series_uids,
            "case": case,
            "user": user,
            "case_series_form": case_series_form,
        },
    )


@login_required
@require_POST
def uploads_import_htmx(request, case_id: int | None = None):
    """HTMX endpoint to import uploaded DICOMs with user-facing status output.

    Scope: Authenticated Atlas users.

    Functionality:
    - Imports selected uploaded series (or all pending uploads if none selected).
    - Optionally associates imported series with a target case.
    - Returns an HTML fragment suitable for HTMX swap into the uploads page.
    """
    selected_uids = [
        uid.strip() for uid in request.POST.getlist("selection") if uid.strip()
    ]

    dicoms = UncategorisedDicom.objects.filter(user=request.user)
    if selected_uids:
        dicoms = dicoms.filter(series_instance_uid__in=selected_uids)

    target_case = None
    if case_id is not None:
        target_case = get_object_or_404(Case, pk=case_id)
        if not target_case.check_user_can_edit(request.user):
            return HttpResponse(
                '<div class="alert alert-danger">You do not have permission to import into this case.</div>',
                status=403,
            )

    if not dicoms.exists():
        return HttpResponse(
            '<div class="alert alert-warning mb-0">No pending uploads were found for the selected series.</div>'
        )

    try:
        # Import helper lives in the API module and handles the core create/update logic.
        from .api import import_dicoms_helper

        imported = import_dicoms_helper(request, case_id=case_id, dicoms=dicoms)
    except Exception as exc:
        logger.exception("Error during uploads import")
        return HttpResponse(
            f'<div class="alert alert-danger mb-0">Import failed: {escape(str(exc))}</div>',
            status=500,
        )

    imported_rows = [
        {
            "series": series,
            "link": link,
        }
        for series, link in imported
    ]

    html = render_to_string(
        "atlas/partials/_upload_import_result.html",
        {
            "imported_rows": imported_rows,
            "imported_count": len(imported_rows),
            "requested_count": len(selected_uids) if selected_uids else None,
            "target_case": target_case,
        },
        request=request,
    )
    response = HttpResponse(html)
    response["HX-Trigger"] = json.dumps(
        {
            "atlas:uploadsImported": {
                "imported_count": len(imported_rows),
                "requested_count": len(selected_uids)
                if selected_uids
                else len(imported_rows),
            }
        }
    )
    return response


@login_required
@require_POST
def uploads_import_case_series_htmx(request):
    """Import selected uploaded studies into a linked chain of cases.

    Scope: Authenticated Atlas users.

    Functionality:
    - Groups the selected uploaded series by study.
    - Creates one case per selected study using the provided CaseForm values.
    - Links each newly created case to the previous one with previous_case.
    - Imports the selected series for each study into its corresponding case.
    """
    selected_uids = [
        uid.strip() for uid in request.POST.getlist("selection") if uid.strip()
    ]
    if not selected_uids:
        return HttpResponse(
            '<div class="alert alert-warning mb-0">Select at least one series before creating a case chain.</div>',
            status=400,
        )

    form = CaseForm(request.POST, user=request.user)
    if not form.is_valid():
        return HttpResponse(
            '<div class="alert alert-danger mb-0">Please correct the case details before importing.</div>',
            status=400,
        )

    dicoms = UncategorisedDicom.objects.filter(
        user=request.user, series_instance_uid__in=selected_uids
    ).select_related("user")
    if not dicoms.exists():
        return HttpResponse(
            '<div class="alert alert-warning mb-0">No pending uploads were found for the selected series.</div>',
            status=404,
        )

    series_to_study = {}
    for dicom in dicoms.iterator():
        tags = dicom.basic_dicom_tags or {}
        series_uid = tags.get("SeriesInstanceUID")
        if not series_uid:
            continue
        study_uid = tags.get("StudyInstanceUID") or tags.get("StudyID") or "unknown"
        series_to_study.setdefault(series_uid, study_uid)

    study_groups = defaultdict(list)
    ordered_study_uids = []
    seen_study_uids = set()
    for series_uid in selected_uids:
        study_uid = series_to_study.get(series_uid)
        if not study_uid:
            continue
        study_groups[study_uid].append(series_uid)
        if study_uid not in seen_study_uids:
            seen_study_uids.add(study_uid)
            ordered_study_uids.append(study_uid)

    if not study_groups:
        return HttpResponse(
            '<div class="alert alert-warning mb-0">The selected uploads did not contain any study metadata.</div>',
            status=400,
        )

    base_case = form.save(commit=False)
    case_field_names = {
        field.name
        for field in Case._meta.concrete_fields
        if field.name not in {"id", "previous_case"}
    }
    m2m_field_names = [
        "subspecialty",
        "condition",
        "presentation",
        "procedures",
        "pathological_process",
    ]

    created_cases = []
    import_results = []
    previous_case = None

    try:
        from .api import import_dicoms_helper

        with transaction.atomic():
            for study_uid in ordered_study_uids:
                case = Case()
                for field_name in case_field_names:
                    setattr(case, field_name, getattr(base_case, field_name))
                case.previous_case = previous_case
                study_series_uids = study_groups[study_uid]
                study_dicoms = dicoms.filter(series_instance_uid__in=study_series_uids)
                case.study_date = _extract_study_date_from_dicoms(study_dicoms)
                case.save()
                case.author.add(request.user)

                for field_name in m2m_field_names:
                    getattr(case, field_name).set(form.cleaned_data.get(field_name, []))

                imported_rows = import_dicoms_helper(
                    request, case_id=case.pk, dicoms=study_dicoms
                )
                created_cases.append(case)
                import_results.append((case, study_uid, imported_rows))
                previous_case = case
    except Exception as exc:
        return HttpResponse(
            f'<div class="alert alert-danger mb-0">Linked import failed: {escape(str(exc))}</div>',
            status=500,
        )

    created_case_links = "".join(
        f'<li><a href="{case.get_absolute_url()}">{escape(str(case))}</a> <span class="text-muted">({escape(study_uid)})</span></li>'
        for case, study_uid, _ in import_results
    )
    imported_series_count = sum(len(rows) for _, _, rows in import_results)
    html = (
        '<div class="alert alert-success mb-3">'
        f"Created {len(created_cases)} linked case{'s' if len(created_cases) != 1 else ''} and imported {imported_series_count} series."
        "</div>"
        '<ul class="mb-0">'
        f"{created_case_links}"
        "</ul>"
    )
    response = HttpResponse(html)
    response["HX-Trigger"] = json.dumps(
        {
            "atlas:uploadsImported": {
                "imported_count": imported_series_count,
                "created_case_count": len(created_cases),
            }
        }
    )
    return response


class CaseDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView):
    model = Case
    success_url = reverse_lazy("atlas:case_view")
    template_name = "confirm_delete.html"


# TODO fix permissions
class SeriesFindingDelete(RevisionMixin, PermissionRequiredMixin, DeleteView):
    permission_required = "atlas.delete_seriesfinding"
    model = SeriesFinding
    template_name = "confirm_delete.html"
    # success_url = reverse_lazy("atlas:case_view")

    def get_object(self):
        series_finding = super().get_object()
        series = series_finding.series
        if series.check_user_can_edit(self.request.user):
            return series_finding
        else:
            raise PermissionDenied

    def get_success_url(self):
        pk = self.kwargs["pk"]
        series_id = get_object_or_404(SeriesFinding, pk=pk).series.id
        return reverse("atlas:series_detail", kwargs={"pk": series_id})


class SeriesDelete(RevisionMixin, AtlasEditorRequiredMixin, DeleteView):
    model = Series
    template_name = "confirm_delete.html"
    success_url = reverse_lazy("atlas:series_view")


class CaseCollectionDelete(RevisionMixin, AtlasEditorRequiredMixin, DeleteView):
    model = CaseCollection
    template_name = "confirm_delete.html"
    success_url = reverse_lazy("atlas:collection_view")


class ConditionDelete(RevisionMixin, AtlasEditorRequiredMixin, DeleteView):
    model = Condition
    template_name = "confirm_delete.html"
    success_url = reverse_lazy("atlas:condition_view")


class FindingDelete(RevisionMixin, AtlasEditorRequiredMixin, DeleteView):
    model = Finding
    template_name = "confirm_delete.html"
    success_url = reverse_lazy("atlas:finding_view")


class StructureDelete(RevisionMixin, AtlasEditorRequiredMixin, DeleteView):
    model = Structure
    template_name = "confirm_delete.html"
    success_url = reverse_lazy("atlas:structure_view")


class ResourceDelete(LoginRequiredMixin, AuthorOrCheckerRequiredMixin, DeleteView):
    model = Resource
    template_name = "confirm_delete.html"

    success_url = reverse_lazy("atlas:resource_view")


class ResourceCreate(LoginRequiredMixin, CreateView):
    model = Resource
    form_class = ResourceForm

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        if self.request.GET.get("popup") == "1":
            context["base_template"] = "atlas/base_popup.html"
        else:
            context["base_template"] = "atlas/base.html"
        return context

    def form_valid(self, form):
        self.object = form.save(commit=False)
        self.object.save()
        form.instance.author.add(self.request.user.id)

        if self.request.GET.get("popup") == "1":
            field_id = self.request.GET.get("field_id", "")
            from django.utils.html import escapejs
            return HttpResponse(
                f'<script type="text/javascript">'
                f'opener.closePopup(window, "{self.object.pk}", "{escapejs(str(self.object))}", "#{field_id}");'
                f'</script>'
            )

        return super().form_valid(form)


class ResourceView(
    LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView
):
    model = Resource
    table_class = ResourceTable
    template_name = "atlas/resource_list.html"
    filterset_class = ResourceFilter

    def get_queryset(self):
        # Base queryset
        qs = Resource.objects.all()

        # Atlas editors and superusers see everything
        if (
            self.request.user.is_superuser
            or self.request.user.groups.filter(name="atlas_editor").exists()
        ):
            return qs

        # Otherwise only show open access resources or those authored by the user
        return qs.filter(
            Q(open_access=True) | Q(author__id=self.request.user.id)
        ).distinct()


class CaseCollectionCreate(RevisionMixin, LoginRequiredMixin, CreateView):
    model = CaseCollection
    form_class = CaseCollectionForm
    template_name = "atlas/casecollection_form.html"

    def get_initial(self):
        # print(self.request)
        if "pk" in self.kwargs:
            initial = super(CaseCollectionCreate, self).get_initial()
            # case = get_object_or_404(Case, pk=self.kwargs["pk"])

            # initial["case"] = case.id

            return initial

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

    def get_context_data(self, **kwargs):
        context = super(CaseCollectionCreate, self).get_context_data(**kwargs)
        # Only bind the case formset when the POST actually contains formset data.
        # We don't require adding cases on create, so if the template doesn't
        # include the casedetail management fields we should skip validation.
        if self.request.POST and "casedetail_set-TOTAL_FORMS" in self.request.POST:
            context["case_formset"] = CaseCollectionCaseFormSet(
                self.request.POST,
                self.request.FILES,
                form_kwargs={"user": self.request.user},
            )
            context["case_formset"].full_clean()
        else:
            context["case_formset"] = CaseCollectionCaseFormSet(
                form_kwargs={"user": self.request.user}
            )
        return context

    def form_valid(self, form):
        self.object = form.save(commit=False)
        self.object.save()
        form.instance.author.add(self.request.user.id)

        context = self.get_context_data(form=form)
        case_formset = context["case_formset"]
        # If the POST included case formset data, validate and save it. Otherwise
        # skip formset processing so creating a collection without cases redirects
        # normally to the detail view.
        if self.request.POST and "casedetail_set-TOTAL_FORMS" in self.request.POST:
            if case_formset.is_valid():
                response = super().form_valid(form)
                case_formset.instance = self.object
                case_formset.save()
                return response
            else:
                return super().form_invalid(form)
        # No formset data present: proceed with normal create redirect.
        return super().form_valid(form)

    def get_success_url(self):
        """Redirect to the collection detail page after successful creation."""
        return reverse("atlas:collection_detail", kwargs={"pk": self.object.pk})


class SeriesCreate(RevisionMixin, LoginRequiredMixin, CreateView):
    model = Series
    form_class = SeriesForm

    def get_initial(self):
        # print(self.request)
        if "pk" in self.kwargs:
            initial = super().get_initial()
            case = get_object_or_404(Case, pk=self.kwargs["pk"])

            initial["case"] = case.id

            return initial

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

    def get_context_data(self, **kwargs):
        context = super(SeriesCreate, self).get_context_data(**kwargs)

        if self.request.POST:
            context["image_formset"] = SeriesImageFormSet(
                self.request.POST, self.request.FILES
            )
            context["image_formset"].full_clean()
        else:
            context["image_formset"] = SeriesImageFormSet()
        return context

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

        form.instance.author.add(self.request.user.id)

        context = self.get_context_data(form=form)
        series_formset = context["image_formset"]
        if series_formset.is_valid():
            response = super().form_valid(form)
            series_formset.instance = self.object
            series_formset.save()
            return response

        else:
            return super().form_invalid(form)


class CaseCollectionUpdate(RevisionMixin, AuthorOrCheckerRequiredMixin, UpdateView):
    model = CaseCollection
    form_class = CaseCollectionForm
    template_name = "atlas/casecollection_form.html"

    #    fields = '__all__'
    #    #fields = [ 'condition' ]
    #    #initial = {'date_of_death': '05/01/2018'}
    #    exclude = [ 'created_date', 'published_date' ]

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

    def get_context_data(self, **kwargs):
        context = super(CaseCollectionUpdate, self).get_context_data(**kwargs)
        # if self.request.POST:
        #    context["case_formset"] = CaseCollectionCaseFormSet(
        #        self.request.POST,
        #        self.request.FILES,
        #        instance=self.object,
        #        form_kwargs={"user": self.request.user, "collection": self.object},
        #    )
        #    context["case_formset"].full_clean()
        # else:
        #    context["case_formset"] = CaseCollectionCaseFormSet(
        #        instance=self.object,
        #        form_kwargs={"user": self.request.user, "collection": self.object},
        #    )

        context["collection"] = context["casecollection"]
        return context

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

        form.instance.author.add(self.request.user.id)

        return super().form_valid(form)
        context = self.get_context_data(form=form)
        case_formset = context["case_formset"]
        # logger.debug(formset.is_valid())
        if case_formset.is_valid():
            response = super().form_valid(form)
            case_formset.instance = self.object
            case_formset.save()

            # restore exam orders
            # for exam in self.object.exams.all():
            #    if exam in exam_orders and self.object in exam_orders[exam]:
            #        print(exam_orders[exam])
            #        exam.exam_questions.set(exam_orders[exam])
            #        exam.save()

            return response
        else:
            return super().form_invalid(form)


class SeriesUpdate(
    RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
):
    model = Series
    form_class = SeriesForm

    #    fields = '__all__'
    #    #fields = [ 'condition' ]
    #    #initial = {'date_of_death': '05/01/2018'}
    #    exclude = [ 'created_date', 'published_date' ]
    def get_context_data(self, **kwargs):
        context = super(SeriesUpdate, self).get_context_data(**kwargs)
        if self.request.POST:
            context["image_formset"] = SeriesImageFormSet(
                self.request.POST, self.request.FILES, instance=self.object
            )

            context["image_formset"].full_clean()
        else:
            context["image_formset"] = SeriesImageFormSet(instance=self.object)
        return context

    def get_form_kwargs(self):
        kwargs = super(SeriesUpdate, 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()

        form.instance.author.add(self.request.user.id)

        context = self.get_context_data(form=form)
        image_formset = context["image_formset"]
        # logger.debug(formset.is_valid())
        if image_formset.is_valid():
            response = super().form_valid(form)
            image_formset.instance = self.object
            image_formset.save()
            return response
        else:
            return super().form_invalid(form)


class ResourceUpdate(LoginRequiredMixin, UpdateView):
    model = Resource
    form_class = ResourceForm

    def form_valid(self, form):
        self.object = form.save(commit=False)
        self.object.save()
        form.instance.author.add(self.request.user.id)

        return super().form_valid(form)


class ConditionUpdate(RevisionMixin, LoginRequiredMixin, UpdateView):
    model = Condition
    form_class = ConditionForm


class ConditionCreate(RevisionMixin, LoginRequiredMixin, CreateView):
    model = Condition
    form_class = ConditionForm


class QuestionSchemaCreate(LoginRequiredMixin, CreateView):
    model = QuestionSchema
    form_class = QuestionSchemaForm


class QuestionSchemaUpdate(AuthorOrCheckerRequiredMixin, UpdateView):
    model = QuestionSchema
    form_class = QuestionSchemaForm


class QuestionSchemaDelete(AuthorOrCheckerRequiredMixin, DeleteView):
    model = QuestionSchema
    template_name = "confirm_delete.html"


class FindingUpdate(RevisionMixin, LoginRequiredMixin, UpdateView):
    model = Finding
    form_class = FindingForm


class FindingCreate(RevisionMixin, LoginRequiredMixin, CreateView):
    model = Finding
    form_class = FindingForm


class StructureUpdate(RevisionMixin, LoginRequiredMixin, UpdateView):
    model = Structure
    form_class = StructureForm


class StructureCreate(RevisionMixin, LoginRequiredMixin, CreateView):
    model = Structure
    form_class = StructureForm


class SubspecialtyCreate(RevisionMixin, LoginRequiredMixin, CreateView):
    model = Subspecialty
    form_class = SubspecialtyForm
    template_name = "atlas/subspecialty_form.html"


class PresentationCreate(RevisionMixin, LoginRequiredMixin, CreateView):
    model = Presentation
    form_class = PresentationForm
    template_name = "atlas/presentation_form.html"


class PathologicalProcessCreate(RevisionMixin, LoginRequiredMixin, CreateView):
    model = PathologicalProcess
    form_class = PathologicalProcessForm
    template_name = "atlas/pathological_process_form.html"


class AtlasCreateBase(RevisionMixin, LoginRequiredMixin):
    model = Case
    form_class = CaseForm

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

    def get_success_url(self):
        next_url = self.request.GET.get("next")
        if next_url:
            return next_url
        return self.object.get_absolute_url()

    def get_context_data(self, **kwargs):
        context = super(AtlasCreateBase, self).get_context_data(**kwargs)
        if self.request.POST:
            context["casedifferential_formset"] = CaseDifferentialFormSet(
                self.request.POST, self.request.FILES
            )
            context["caseresource_formset"] = CaseResourceFormSet(
                self.request.POST,
                self.request.FILES,
                form_kwargs={"user": self.request.user},
            )
            context["casedifferential_formset"].full_clean()
            context["caseresource_formset"].full_clean()
        else:
            context["casedifferential_formset"] = CaseDifferentialFormSet()
            context["caseresource_formset"] = CaseResourceFormSet(
                form_kwargs={"user": self.request.user}
            )
        next_url = self.request.GET.get("next")
        if not next_url:
            if self.object and self.object.pk:
                next_url = self.object.get_absolute_url()
            else:
                next_url = reverse("atlas:case_view")
        context["next_url"] = next_url
        return context

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

        # Add exam objects

        self.object.save()

        form.instance.author.add(self.request.user.id)

        # Check Cimar Case
        # CimarAPI().get_study_by_uuid(self.object.cimar_uuid)

        context = self.get_context_data(form=form)
        # series_formset = context["series_formset"]
        casedifferential_formset = context["casedifferential_formset"]
        caseresource_formset = context["caseresource_formset"]
        if (
            # series_formset.is_valid()
            casedifferential_formset.is_valid() and caseresource_formset.is_valid()
        ):
            response = super().form_valid(form)
            # series_formset.instance = self.object
            # series_formset.save()
            casedifferential_formset.instance = self.object
            casedifferential_formset.save()
            caseresource_formset.instance = self.object
            caseresource_formset.save()
            # If the normal submit button is pressed we save as normal
            if "submit" in self.request.POST:
                return response
            # else we redirect to the clone url
            else:
                return redirect("atlas:case_clone", pk=self.object.pk)

        else:
            return super().form_invalid(form)


# @login_required
# CaseCreate
class AtlasCreate(AtlasCreateBase, CreateView):
    def get_initial(self):
        if "pk" in self.kwargs:
            initial = super().get_initial()
            exam = get_object_or_404(CaseCollection, pk=self.kwargs["pk"])

            initial["exams"] = [exam.id]

            return initial
        ## There has to be a better way...
        # try:
        #    s = (i.pk for i in self.request.user.atlas_default.site.all())
        #    self.initial.update({"site": s})
        # except AttributeError:
        #    pass
        # return self.initial


# CaseUpdate
class AtlasUpdate(
    RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
):
    model = Case
    form_class = CaseForm

    #    fields = '__all__'
    #    #fields = [ 'condition' ]
    #    #initial = {'date_of_death': '05/01/2018'}
    #    exclude = [ 'created_date', 'published_date' ]

    # def get_object(self, queryset=None):
    #    # Prefetch all related objects needed for the form and formsets
    #    qs = self.model.objects.select_related(
    #        # Add all FK fields here
    #    ).prefetch_related(
    #        "series__examination",
    #        "series__plane",
    #        "series__contrast",
    #        "series__images",
    #        "subspecialty",
    #        "condition",
    #        "presentation",
    #        "pathological_process",
    #        "differentialcase",
    #        "caseresource_set",
    #    )
    #    return qs.get(pk=self.kwargs["pk"])

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

    def get_success_url(self):
        next_url = self.request.GET.get("next")
        if next_url:
            return next_url
        return self.object.get_absolute_url()

    def get_context_data(self, **kwargs):
        context = super(AtlasUpdate, self).get_context_data(**kwargs)
        if self.request.POST:
            context["casedifferential_formset"] = CaseDifferentialFormSet(
                self.request.POST, self.request.FILES, instance=self.object
            )
            context["caseresource_formset"] = CaseResourceFormSet(
                self.request.POST,
                self.request.FILES,
                instance=self.object,
                form_kwargs={"user": self.request.user},
            )
            context["casedifferential_formset"].full_clean()
            context["caseresource_formset"].full_clean()
        else:
            context["casedifferential_formset"] = CaseDifferentialFormSet(
                instance=self.object
            )
            context["caseresource_formset"] = CaseResourceFormSet(
                instance=self.object,
                form_kwargs={"user": self.request.user},
            )
        next_url = self.request.GET.get("next")
        if not next_url:
            if self.object and self.object.pk:
                next_url = self.object.get_absolute_url()
            else:
                next_url = reverse("atlas:case_view")
        context["next_url"] = next_url
        return context

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

        form.instance.author.add(self.request.user.id)

        context = self.get_context_data(form=form)
        # series_formset = context["series_formset"]
        casedifferential_formset = context["casedifferential_formset"]
        caseresource_formset = context["caseresource_formset"]
        # logger.debug(formset.is_valid())
        if (
            # series_formset.is_valid()
            casedifferential_formset.is_valid() and caseresource_formset.is_valid()
        ):
            response = super().form_valid(form)
            # series_formset.instance = self.object
            # series_formset.save()
            casedifferential_formset.instance = self.object
            casedifferential_formset.save()
            caseresource_formset.instance = self.object
            caseresource_formset.save()

            return response
        else:
            return super().form_invalid(form)


class CollectionCaseUpdate(
    RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
):
    model = CaseCollection
    form_class = CaseCollectionUpdateCaseForm
    template_name = "atlas/casecollection_update_case.html"

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

        return kwargs

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        if self.request.POST:
            context["case_formset"] = CaseCollectionCaseFormSet(
                self.request.POST,
                self.request.FILES,
                instance=self.object,
                form_kwargs={"user": self.request.user, "collection": self.object},
            )
            context["case_formset"].full_clean()
        else:
            context["case_formset"] = CaseCollectionCaseFormSet(
                instance=self.object,
                form_kwargs={"user": self.request.user, "collection": self.object},
            )
        context["collection"] = context["casecollection"]
        return context

    def form_valid(self, form):
        context = self.get_context_data(form=form)
        case_formset = context["case_formset"]
        if case_formset.is_valid():
            case_formset.instance = self.object
            case_formset.save()
            return super().form_valid(form)
        else:
            return self.form_invalid(form)


class AtlasSeriesUpdate(
    RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
):
    model = Case
    form_class = CaseUpdateSeriesForm
    template_name = "atlas/case_update_series.html"

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

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        is_atlas_editor = (
            self.request.user.groups.filter(name="atlas_editor").exists()
            or self.request.user.is_superuser
        )
        form_kwargs = {"user": self.request.user, "is_atlas_editor": is_atlas_editor}

        if not is_atlas_editor:
            queryset = Series.objects.filter(author__id=self.request.user.id)
        else:
            queryset = Series.objects.all()
        queryset = queryset.select_related("modality")

        if self.request.POST:
            context["series_formset"] = SeriesFormSet(
                self.request.POST,
                self.request.FILES,
                queryset=queryset,
                instance=self.object,
                form_kwargs=form_kwargs,
            )
            context["series_formset"].full_clean()
        else:
            context["series_formset"] = SeriesFormSet(
                queryset=queryset,
                instance=self.object,
                form_kwargs=form_kwargs,
            )
        return context

    def form_valid(self, form):
        context = self.get_context_data(form=form)
        series_formset = context["series_formset"]
        if series_formset.is_valid():
            series_formset.instance = self.object
            series_formset.save()
            return super().form_valid(form)
        else:
            return self.form_invalid(form)

    def get_object(self, queryset=None):
        qs = Case.objects.select_related().prefetch_related(
            "seriesdetail_set__series__modality",
            "seriesdetail_set__series__examination",
            "seriesdetail_set__series__plane",
            "seriesdetail_set__series__contrast",
            "seriesdetail_set__series__images",
        )
        return qs.get(pk=self.kwargs["pk"])


def add_collection_to_case_form(request, case_id):
    if request.method == "POST":
        form = AddCollectionToCaseForm(request.POST, user=request.user)
        if form.is_valid():
            case = get_object_or_404(Case, pk=case_id)

            case.casecollection_set.add(*form.cleaned_data["casecollection"])
            # do_something_with_form_data(form.cleaned_data)
            return HttpResponse("Done")
            return render(request, "web/htmx_contact_form_confirm.html")
    else:
        form = AddCollectionToCaseForm(user=request.user)

    # Resolve the Case early so templates always receive it when available
    try:
        case = Case.objects.get(pk=case_id)
    except Exception:
        case = None

    # If called via HTMX (inline from case view) return a compact fragment
    is_htmx = (
        getattr(request, "htmx", False)
        or request.headers.get("HX-Request", "").lower() == "true"
    )
    if is_htmx:
        # Provide the 10 most recently updated collections for this user by default
        user = request.user
        recent_base = (
            CaseCollection.objects.filter(Q(author=user) | Q(markers=user))
            .distinct()
            .order_by("-updated")
        )
        # Exclude collections that already contain the case (if resolved)
        if case is not None:
            recent_base = recent_base.exclude(
                pk__in=case.casecollection_set.values_list("pk", flat=True)
            )
        recent_qs = recent_base[:10]

        html = render_to_string(
            "atlas/partials/add_collection_inline.html",
            {"recent_collections": recent_qs, "case": case},
            request=request,
        )
        return HttpResponse(html)

    return render(
        request,
        "generic_form.html",
        {
            "form": form,
            "target_url": reverse("atlas:case_collection_form", args=(case_id,)),
        },
    )


@login_required
def collection_inline_search(request, case_id):
    # HTMX endpoint: return recent collections or search results for the user
    if not request.htmx:
        return Http404

    q = request.GET.get("q", "").strip()
    user = request.user

    base_qs = CaseCollection.objects.filter(
        Q(author=user)
        | Q(markers=user)
        | Q(valid_user_users=user)
        | Q(open_access=True)
    ).distinct()

    try:
        case = Case.objects.get(pk=case_id)
    except Exception:
        case = None

    # Exclude collections already containing the case
    if case is not None:
        base_qs = base_qs.exclude(
            pk__in=case.casecollection_set.values_list("pk", flat=True)
        )

    if q:
        results = base_qs.filter(name__icontains=q).order_by("-updated")[:30]
    else:
        results = base_qs.order_by("-updated")[:10]

    html = render_to_string(
        "atlas/partials/collection_inline_list.html",
        {"collections": results, "case": case},
        request=request,
    )
    return HttpResponse(html)


@login_required
@require_http_methods(["POST"])
def add_collection_to_case_inline(request, case_id, collection_id):
    # HTMX endpoint to add a single collection to a case and return the new list item
    if not request.htmx:
        return Http404

    case = get_object_or_404(Case, pk=case_id)
    collection = get_object_or_404(CaseCollection, pk=collection_id)

    # Permission: user must be able to edit the case
    if not case.check_user_can_edit(request.user):
        return HttpResponse(
            "You do not have permission to add collections to this case.", status=403
        )

    # Add the association
    if collection in case.casecollection_set.all():
        return HttpResponse("Already in collection")

    case.casecollection_set.add(collection)

    # Render a list item to insert into the case's collection list
    rendered = render_to_string(
        "atlas/partials/collection_list_item.html",
        {
            "collection": collection,
            "case": case,
            "can_edit": case.check_user_can_edit(request.user),
        },
        request=request,
    )

    # Also instruct the client to remove the collection entry from the inline search list
    remove_script = (
        "<script>"
        "(function(){"
        "try {"
        "var pk = '%s';"
        % collection.pk
        + "document.querySelectorAll('#collection-inline-list [data-collection-pk=\"' + pk + '\"]').forEach(function(el){ el.remove(); });"
        + '} catch(e) { console.error("remove added collection from inline list error", e); }'
        "})();"
        "</script>"
    )

    return HttpResponse(rendered + remove_script)


# class AddCollectionToCaseView(FormView):
#    form_class = AddCollectionToCaseForm
#    template_name = "generic_form.html"
#
#    def form_valid(self, form):
#        form.save()
#        return super(AddCollectionToCaseView, self).form_valid(form)
#
#    def get_form_kwargs(self):
#        kwargs = super(AddCollectionToCaseView, self).get_form_kwargs()
#        kwargs.update({"user": self.request.user})
#        return kwargs
#
#    def get_success_url(self) -> str:
#        pk = self.kwargs["pk"]
#        return reverse("atlas:case_detail", kwargs={"pk": pk})


class AtlasClone(AtlasCreateBase, AuthorOrCheckerRequiredMixin, CreateView):
    """Clones a existing atlas case"""

    #    fields = '__all__'
    #    #fields = [ 'condition' ]
    #    #initial = {'date_of_death': '05/01/2018'}
    #    exclude = [ 'created_date', 'published_date' ]
    def get_initial(self):
        # print(self.request)
        old_object = get_object_or_404(Case, pk=self.kwargs["pk"])

        if (
            self.request.user.groups.filter(name="atlas_editor").exists()
            or self.request.user.is_superuser
        ):
            pass
        else:
            if self.request.user not in old_object.get_author_objects():
                raise PermissionDenied()  # or Http404
            else:
                pass

        initial_data = model_to_dict(
            old_object,
            exclude=["id", "previous_case", "history", "discussion", "report"],
        )
        return initial_data


class AtlasCloneNext(AtlasClone):
    """Extends AtlasClone to set the previous_case field to the case being cloned"""

    def get_initial(self):
        initial_data = super().get_initial()
        initial_data["previous_case"] = self.kwargs["pk"]
        return initial_data


@login_required
@user_is_author_or_atlas_editor
def atlas_scrap(request, pk):
    try:
        atlas = Case.objects.get(pk=pk)
    except Case.DoesNotExist:
        raise Http404("Case does not exist")

    atlas.scrapped = not atlas.scrapped
    atlas.save()
    return HttpResponseRedirect(reverse("atlas:case_detail", args=(pk,)))


# @login_required
# def edit_abnormality_popup(request):
#     instance = get_object_or_404(Abnormality, pk = pk)
#     form = AbnormalityForm(request.POST or None)
#     if form.is_valid():
#         instance = form.save()
#         return HttpResponse('<script>opener.closePopup(window, "%s", "%s", "#id_abnormality");</script>' % (instance.pk, instance))
#     return render(request, "atlas/create_simple.html", {'form': form})


@login_required
def get_examination_id(request):
    """Return the primary key for an Examination looked up by name (admin popup helper).

    Scope: authenticated users only.
    Functionality: looks up an Examination by exact name and returns its ID as JSON;
    used by inline admin form popups.
    """
    if request.accepts("application/json"):
        examination_name = request.GET.get("examination_name", "")
        examination_id = Examination.objects.get(name=examination_name).id
        data = {
            "examination_id": examination_id,
        }
        return HttpResponse(json.dumps(data), content_type="application/json")
    return HttpResponse("/")


class CaseView(
    LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView
):
    model = Case
    table_class = CaseTable
    template_name = "atlas/case_view.html"

    filterset_class = CaseFilter


@staff_member_required
def case_admin_overview(request):
    """Admin overview for case ownership and storage-heavy case monitoring.

    Scope: staff users.
    Functionality: shows per-author case counts and summary metrics, with recent
    and largest case lists loaded on demand via HTMX partials.
    """
    author_rows = (
        User.objects.filter(atlas_authored_cases__isnull=False)
        .annotate(case_count=Count("atlas_authored_cases", distinct=True))
        .order_by("-case_count", "username")
    )

    totals = {
        "total_cases": Case.objects.count(),
        "total_authors": author_rows.count(),
        "open_access_cases": Case.objects.filter(open_access=True).count(),
        "archived_cases": Case.objects.filter(archive=True).count(),
    }

    return render(
        request,
        "atlas/case_admin_overview.html",
        {
            "author_rows": author_rows,
            "totals": totals,
        },
    )


@staff_member_required
def case_admin_recent_cases_partial(request):
    """HTMX partial for recently added cases in the admin overview."""
    recent_cases = Case.objects.prefetch_related("author").order_by("-created_date")[
        :30
    ]
    return render(
        request,
        "atlas/partials/_case_admin_recent_cases.html",
        {"recent_cases": recent_cases},
    )


@staff_member_required
def case_admin_largest_cases_partial(request):
    """HTMX partial for largest cases by total series image size."""

    def human_size(num_bytes):
        n = float(num_bytes or 0)
        for unit in ("B", "KB", "MB", "GB", "TB"):
            if n < 1024.0:
                return f"{n:.1f}{unit}"
            n /= 1024.0
        return f"{n:.1f}PB"

    rows = []
    for case in Case.objects.prefetch_related("series__images").all():
        try:
            total = case.get_total_series_images_size()
        except Exception:
            total = 0
        rows.append(
            {
                "case": case,
                "total": total,
                "human_total": human_size(total),
                "series_count": case.series.count(),
            }
        )

    rows.sort(key=lambda row: row["total"], reverse=True)
    return render(
        request,
        "atlas/partials/_case_admin_largest_cases.html",
        {"largest_rows": rows[:30]},
    )


@staff_member_required
def cases_by_size(request):
    """Admin view: list cases ordered by total images size to find large cases.

    Shows case id, title, number of series, number of images and total size (bytes).
    Optional GET parameter `min_size` (bytes) to filter cases larger than that.
    """
    min_size = request.GET.get("min_size")
    try:
        min_size = int(min_size) if min_size else 0
    except Exception:
        min_size = 0

    cases_qs = Case.objects.prefetch_related("series__images")

    def human(n):
        # simple human readable bytes
        for unit in ("B", "KB", "MB", "GB", "TB"):
            if n < 1024.0:
                return f"{n:.1f}{unit}"
            n /= 1024.0
        return f"{n:.1f}PB"

    rows = []
    for c in cases_qs:
        try:
            total = c.get_total_series_images_size()
        except Exception:
            total = 0
        series_count = c.series.count()
        image_count = sum(
            s.images.filter(removed=False).count() for s in c.series.all()
        )
        if total >= min_size:
            rows.append(
                {
                    "case": c,
                    "total": total,
                    "series_count": series_count,
                    "image_count": image_count,
                    "human_total": human(total),
                }
            )

    rows.sort(key=lambda r: r["total"], reverse=True)

    rows.sort(key=lambda r: r["total"], reverse=True)

    paginator = Paginator(rows, 50)
    page_num = request.GET.get("page")
    page = paginator.get_page(page_num)

    def human(n):
        # simple human readable bytes
        for unit in ("B", "KB", "MB", "GB", "TB"):
            if n < 1024.0:
                return f"{n:.1f}{unit}"
            n /= 1024.0
        return f"{n:.1f}PB"

    context = {"page": page, "human": human, "min_size": min_size}
    return render(request, "atlas/cases_by_size.html", context)


class SeriesView(
    LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView
):
    model = Series
    table_class = SeriesTable
    template_name = "atlas/series_view.html"

    filterset_class = SeriesFilter


class ConditionView(
    LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView
):
    model = Condition
    table_class = ConditionTable
    template_name = "atlas/view.html"

    filterset_class = ConditionFilter

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        try:
            ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
        except NoReverseMatch:
            ctx["create_url"] = None
        # Provide verbose names to templates without accessing _meta in templates
        ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None)
        ctx["model_verbose_name_plural"] = getattr(
            self.model._meta, "verbose_name_plural", None
        )
        return ctx


class QuestionSchemaView(
    LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView
):
    model = QuestionSchema
    table_class = QuestionSchemaTable
    template_name = "atlas/schema_table_view.html"

    filterset_class = QuestionSchemaFilter


class SubspecialtyView(
    LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView
):
    model = Subspecialty
    table_class = SubspecialtyTable
    template_name = "atlas/view.html"

    filterset_class = SubspecialtyFilter

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        try:
            ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
        except NoReverseMatch:
            ctx["create_url"] = None
        ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None)
        ctx["model_verbose_name_plural"] = getattr(
            self.model._meta, "verbose_name_plural", None
        )
        return ctx


class PresentationView(
    LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView
):
    model = Presentation
    table_class = PresentationTable
    template_name = "atlas/view.html"

    filterset_class = PresentationFilter

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        try:
            ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
        except NoReverseMatch:
            ctx["create_url"] = None
        ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None)
        ctx["model_verbose_name_plural"] = getattr(
            self.model._meta, "verbose_name_plural", None
        )
        return ctx


class ProcedureView(
    LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, ListView
):
    model = Procedure
    table_class = __import__("atlas.tables", fromlist=["ProcedureTable"]).ProcedureTable
    template_name = "atlas/view.html"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        try:
            ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
        except NoReverseMatch:
            ctx["create_url"] = None
        ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None)
        ctx["model_verbose_name_plural"] = getattr(
            self.model._meta, "verbose_name_plural", None
        )
        return ctx


class ProcedureCreate(RevisionMixin, LoginRequiredMixin, CreateView):
    model = Procedure
    form_class = ProcedureForm
    template_name = "atlas/procedure_form.html"

    def dispatch(self, request, *args, **kwargs):
        try:
            logger.debug(
                "ProcedureCreate dispatch: method=%s, user=%s",
                request.method,
                request.user,
            )
            if request.method == "POST":
                logger.debug("ProcedureCreate POST data: %s", request.POST.dict())
        except Exception:
            logger.exception("Error logging ProcedureCreate dispatch")
        return super().dispatch(request, *args, **kwargs)

    def form_valid(self, form):
        self.object = form.save()
        return super().form_valid(form)

    def form_invalid(self, form):
        logger.warning("ProcedureCreate form invalid: %s", form.errors)
        return super().form_invalid(form)

    def get_success_url(self):
        try:
            return reverse("atlas:procedure_detail", kwargs={"pk": self.object.pk})
        except Exception:
            return reverse("atlas:procedure_view")


class ProcedureUpdate(RevisionMixin, LoginRequiredMixin, UpdateView):
    model = Procedure
    form_class = ProcedureForm
    template_name = "atlas/procedure_form.html"

    def form_valid(self, form):
        self.object = form.save()
        return super().form_valid(form)

    def form_invalid(self, form):
        logger.warning("ProcedureUpdate form invalid: %s", form.errors)
        return super().form_invalid(form)

    def get_success_url(self):
        try:
            return reverse("atlas:procedure_detail", kwargs={"pk": self.object.pk})
        except Exception:
            return reverse("atlas:procedure_view")


class ProcedureDelete(RevisionMixin, LoginRequiredMixin, DeleteView):
    model = Procedure
    template_name = "confirm_delete.html"


class PathologicalProcessView(
    LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView
):
    model = PathologicalProcess
    table_class = PathologicalProcessTable
    template_name = "atlas/view.html"

    filterset_class = PathologicalProcessFilter

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        try:
            ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
        except NoReverseMatch:
            ctx["create_url"] = None
        ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None)
        ctx["model_verbose_name_plural"] = getattr(
            self.model._meta, "verbose_name_plural", None
        )
        return ctx


class StructureView(
    LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView
):
    model = Structure
    table_class = StructureTable
    template_name = "atlas/view.html"

    filterset_class = StructureFilter

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        try:
            ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
        except NoReverseMatch:
            ctx["create_url"] = None
        ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None)
        ctx["model_verbose_name_plural"] = getattr(
            self.model._meta, "verbose_name_plural", None
        )
        return ctx


class FindingView(
    LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView
):
    model = Finding
    table_class = FindingTable
    template_name = "atlas/view.html"

    filterset_class = FindingFilter

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        try:
            ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
        except NoReverseMatch:
            ctx["create_url"] = None
        return ctx


@login_required
def case_order_dicom(request, pk):
    if request.htmx:
        case: Case = get_object_or_404(Case, pk=pk)

        fail = []
        # TODO: don't try to order non stack series
        for series in case.series.all():
            try:
                if series.check_user_can_edit(request.user):
                    series.order_by_dicom()
                else:
                    fail.append(f"{series.pk}: invalid permission")
            except:
                fail.append(f"{series.pk}: error")

        if fail:
            return HttpResponse(
                f"Unable to order series {','.join([str(i) for i in fail])}"
            )
        else:
            return HttpResponse("Series reordered")

    raise Http404


@login_required
@user_is_author_or_atlas_series_checker
def series_order_dicom(request, pk):
    series = get_object_or_404(Series, pk=pk)
    try:
        series.order_by_dicom()
    except:
        return HttpResponse("<h1>Series does not appear to contain dicoms</h1>")

    return redirect("atlas:series_detail", pk=pk)


@login_required
def image_diff(request):
    if request.htmx:
        image_ids = request.POST.getlist("image-id")

        if len(image_ids) == 2:
            first: SeriesImage = get_object_or_404(SeriesImage, pk=image_ids[0])
            second: SeriesImage = get_object_or_404(SeriesImage, pk=image_ids[1])

            first_ds = pydicom.dcmread(first.image.file)
            # del first_ds.PixelData
            # j1 = first_ds.to_json_dict()
            f = print_dicom(first_ds, join=False)

            second_ds = pydicom.dcmread(second.image.file)
            # del second_ds.PixelData
            # j2 = second_ds.to_json_dict()
            s = print_dicom(second_ds, join=False)

            diff = difflib.HtmlDiff().make_table(f, s)

            dict_diff = pprint.pformat(compare_dicom_datasets(first_ds, second_ds))

            # return HttpResponse(diff)

            return HttpResponse(f"<pre>{dict_diff}</pre><br/>{diff}")

        else:
            ds_set = []
            for pk in image_ids:
                series_image = get_object_or_404(SeriesImage, pk=pk)
                ds_set.append(pydicom.dcmread(series_image.image.file))

            dict_diff = pprint.pformat(compare_dicom_datasets(*ds_set))
            return HttpResponse(f"<pre>{dict_diff}</pre>")

    return HttpResponse("Fail")


@login_required
def series_image_dicom(request, pk):
    image = get_object_or_404(SeriesImage, pk=pk)

    return render(request, "atlas/series_image_dicom.html", {"image": image})


@login_required
@user_is_author_or_atlas_series_checker
def series_anonymise_dicom(request, pk):
    series = get_object_or_404(Series, pk=pk)

    series.anonymise_images()

    return redirect("atlas:series_detail", pk=pk)


@login_required
@user_is_author_or_atlas_series_checker
def series_order_dicom_instance(request, pk):
    series = get_object_or_404(Series, pk=pk)
    try:
        series.order_by_dicom("InstanceNumber")
    except:
        return HttpResponse(
            "<h1>Series does not appear to contain dicoms (or field InstanceNumber)</h1>"
        )

    return redirect("atlas:series_detail", pk=pk)


@login_required
@user_is_author_or_atlas_series_checker
def series_order_dicom_SeriesInstanceUID(request, pk):
    series = get_object_or_404(Series, pk=pk)
    try:
        series.order_by_dicom("SeriesInstanceUID")
    except:
        return HttpResponse(
            "<h1>Series does not appear to contain dicoms (or field SeriesInstanceUID)</h1>"
        )

    return redirect("atlas:series_detail", pk=pk)


@login_required
@user_is_author_or_atlas_series_checker
def series_order_upload_filename(request, pk):
    series = get_object_or_404(Series, pk=pk)
    series.order_by_upload_filename()

    return redirect("atlas:series_detail", pk=pk)


# Common DICOM tags available for series splitting
SERIES_SPLIT_TAGS = [
    ("ImageType", "Image Type"),
    ("EchoNumbers", "Echo Numbers (multi-echo MRI)"),
    ("EchoTime", "Echo Time"),
    ("DiffusionBValue", "Diffusion B-Value (DWI)"),
    ("TemporalPositionIdentifier", "Temporal Position (dynamic series)"),
    ("AcquisitionNumber", "Acquisition Number"),
    ("FlipAngle", "Flip Angle"),
    ("ContrastBolusAgent", "Contrast/Bolus Agent"),
    ("CardiacNumberOfImages", "Cardiac Number of Images"),
]


def _normalise_dicom_split_value(value):
    if isinstance(value, pydicom.multival.MultiValue):
        return ",".join(map(str, value))
    return str(value)


def _summarise_dicom_value(value):
    if isinstance(value, pydicom.multival.MultiValue):
        value_text = ",".join(map(str, value))
    elif isinstance(value, (bytes, bytearray)):
        value_text = f"<binary:{len(value)} bytes>"
    else:
        value_text = str(value)

    if len(value_text) > 120:
        return f"{value_text[:117]}..."
    return value_text


def _analyse_series_split_options(series):
    tags = [tag_value for tag_value, _ in SERIES_SPLIT_TAGS]
    tag_value_sets = {tag: set() for tag in tags}
    tag_missing = {tag: False for tag in tags}

    for image in series.images.iterator():
        ds = image.get_dicom_data()
        for tag in tags:
            if tag in ds:
                tag_value_sets[tag].add(_normalise_dicom_split_value(ds[tag].value))
            else:
                tag_missing[tag] = True

    split_options = []
    for tag_value, tag_label in SERIES_SPLIT_TAGS:
        if tag_missing[tag_value]:
            continue
        split_count = len(tag_value_sets[tag_value])
        if split_count > 1:
            split_options.append(
                {
                    "tag_value": tag_value,
                    "tag_label": tag_label,
                    "split_count": split_count,
                }
            )

    split_options.sort(key=lambda item: item["split_count"], reverse=True)
    return split_options


@login_required
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
def series_tag_consistency(request, pk):
    """Analyze DICOM tags across all images in a series.

    Scope: Any user with view access to the series.

    Functionality:
    - GET: returns a panel with an optional on-demand analysis.
    - GET ?analyze=1: scans all tags across images and groups them by:
      1) same in all images, 2) different across images, 3) missing in some images.
    """
    series = get_object_or_404(Series, pk=pk)
    analyze_requested = request.GET.get("analyze") == "1"
    can_split = series.check_user_can_edit(request.user)

    same_tags = []
    different_tags = []
    partial_tags = []
    image_count = 0

    if analyze_requested:
        tag_values = defaultdict(set)
        tag_presence = defaultdict(int)

        for image in series.images.iterator():
            image_count += 1
            ds = image.get_dicom_data()
            seen_in_image = set()

            for element in ds.iterall():
                # PixelData is large and not useful for consistency checks.
                if element.tag == (0x7FE0, 0x0010):
                    continue

                tag_name = element.keyword or str(element.tag)
                tag_values[tag_name].add(_summarise_dicom_value(element.value))

                if tag_name not in seen_in_image:
                    tag_presence[tag_name] += 1
                    seen_in_image.add(tag_name)

        for tag_name in sorted(tag_values.keys()):
            present_count = tag_presence[tag_name]
            unique_values = tag_values[tag_name]
            unique_count = len(unique_values)
            sample_values = sorted(unique_values)[:3]

            row = {
                "tag_name": tag_name,
                "present_count": present_count,
                "unique_count": unique_count,
                "sample_values": sample_values,
            }

            if present_count == image_count and unique_count == 1:
                same_tags.append(row)
            elif present_count == image_count:
                different_tags.append(row)
            else:
                partial_tags.append(row)

    return render(
        request,
        "atlas/partials/_series_tag_consistency.html",
        {
            "series": series,
            "analyze_requested": analyze_requested,
            "can_split": can_split,
            "image_count": image_count,
            "same_tags": same_tags,
            "different_tags": different_tags,
            "partial_tags": partial_tags,
        },
    )


@login_required
@user_is_author_or_atlas_series_checker
def series_split_by_tag(request, pk):
    """Split a series into multiple series based on unique values of a chosen DICOM tag.

    Scope: Atlas series authors and series checkers only (via decorator).

    Functionality:
        - GET: renders a partial form and, on demand, analyzes available DICOM tags
            to list only tags that can actually split the series.
    - POST: performs the split. The original series retains the images matching
      the first unique tag value; new series are cloned for each subsequent value.
            On success, returns an HTMX success fragment with number of series created.
      On error (tag not found or non-DICOM images), returns an HTMX-swappable
      error message fragment.
    """
    series = get_object_or_404(Series, pk=pk)

    if request.method == "GET":
        analyze_requested = request.GET.get("analyze") == "1"
        split_options = (
            _analyse_series_split_options(series) if analyze_requested else []
        )
        return render(
            request,
            "atlas/partials/_series_split_by_tag.html",
            {
                "series": series,
                "split_options": split_options,
                "analyze_requested": analyze_requested,
            },
        )

    dicom_tag = request.POST.get("dicom_tag", "").strip()
    if not dicom_tag:
        return HttpResponse(
            '<div class="alert alert-danger">No DICOM tag specified.</div>'
        )

    # Support hex tuple notation e.g. "(0008,0008)"
    if dicom_tag.startswith("(") and dicom_tag.endswith(")"):
        dicom_tag = tuple([hex(int(i.strip(), 16)) for i in dicom_tag[1:-1].split(",")])

    image_map = defaultdict(list)

    for image in series.images.iterator():
        ds = image.get_dicom_data()
        if dicom_tag not in ds:
            return HttpResponse(
                f'<div class="alert alert-danger">Tag <code>{escape(str(dicom_tag))}</code> not found in image {escape(str(image))}.</div>'
            )
        key = _normalise_dicom_split_value(ds[dicom_tag].value)
        image_map[key].append(image)

    if len(image_map) < 2:
        return HttpResponse(
            f'<div class="alert alert-warning">Tag <code>{escape(str(dicom_tag))}</code> has only one unique value, so no split was performed.</div>'
        )

    case = list(series.case.all())
    authors = list(series.author.all())
    new_series_pks = []

    with transaction.atomic():
        for n, (option, images) in enumerate(image_map.items()):
            if n == 0:
                series.images.set(images)
                new_series_pks.append(series.pk)
            else:
                series.pk = None
                series.save()
                series.case.set(case)
                series.author.set(authors)
                series.images.set(images)
                new_series_pks.append(series.pk)

    logger.info(
        "Series split by tag '{}': produced series PKs {}",
        dicom_tag,
        new_series_pks,
    )
    return HttpResponse(
        f'<div class="alert alert-success">Split complete: <strong>{len(new_series_pks)}</strong> series created from tag <code>{escape(str(dicom_tag))}</code>.</div>'
    )


# Vendored in from BaseZipView
class SeriesImagesZipView(SeriesImagesZipViewBase, SuperuserRequiredMixin):
    series_object = Series


@login_required
def case_download(request, pk):
    """Allow a case author (or superuser) to download all linked series DICOMs
    and a metadata export as a zip file.
    """
    case = get_object_or_404(Case, pk=pk)

    # Only allow superusers or case authors
    if not (
        request.user.is_superuser or case.author.filter(id=request.user.id).exists()
    ):
        raise PermissionDenied()

    temp_file = ContentFile(b"", name=f"case_{case.pk}.zip")
    with zipfile.ZipFile(
        temp_file, mode="w", compression=zipfile.ZIP_DEFLATED
    ) as zip_file:
        metadata = {
            "case": {
                "id": case.pk,
                "title": case.title,
                "authors": [str(a) for a in case.author.all()],
                # Non-DICOM metadata
                "description": case.description,
                "history": case.history,
                "discussion": case.discussion,
                "report": case.report,
                "created_date": getattr(case, "created_date", None),
                "modified_date": getattr(case, "modified_date", None),
                "presentation": [str(p) for p in case.presentation.all()],
                "subspecialty": [str(s) for s in case.subspecialty.all()],
                "pathological_process": [
                    str(p) for p in case.pathological_process.all()
                ],
                "procedures": [str(p) for p in case.procedures.all()],
                "conditions": [str(c) for c in case.condition.all()],
                "case_size_bytes": case.get_total_series_images_size()
                if hasattr(case, "get_total_series_images_size")
                else None,
            },
            "series": [],
        }

        # Iterate ordered series if available, otherwise fall back to related series
        try:
            ordered_series = case.get_ordered_series()
        except Exception:
            ordered_series = list(case.series.all())

        for series in ordered_series:
            series_entry = {"pk": series.pk, "description": str(series)}
            # Prefer series-provided dicom JSON if available
            try:
                series_entry["dicom_json"] = series.get_series_dicom_json()
            except Exception:
                series_entry["dicom_json"] = None

            images = series.images.filter(removed=False).order_by("position")
            series_entry["images"] = []
            for img in images:
                try:
                    file_obj = img.image.file
                    fname = os.path.split(file_obj.name)[-1]
                    arcname = f"series_{series.pk}/{fname}"
                    zip_file.writestr(arcname, file_obj.read())
                    series_entry["images"].append(
                        {"filename": fname, "position": img.position}
                    )
                except Exception:
                    # skip missing files
                    continue

            metadata["series"].append(series_entry)

        # Write metadata.json
        zip_file.writestr("metadata.json", json.dumps(metadata, default=str, indent=2))

    file_size = temp_file.tell()
    temp_file.seek(0)

    response = HttpResponse(temp_file, content_type="application/zip")
    response["Content-Disposition"] = "attachment; filename=%s" % f"case_{case.pk}.zip"
    response["Content-Length"] = file_size
    return response


# At the moment only the series owner / author can delete findings but anyone can create
def create_series_findings(request):
    # posts = Post.objects.all()
    response_data = {}

    if request.POST.get("action") == "post":
        series_finding_id = int(request.POST.get("series_finding_id"))
        series_id = request.POST.get("series")
        findings_ids = json.loads(request.POST.get("findings"))
        structure_ids = json.loads(request.POST.get("structures"))
        condition_ids = json.loads(request.POST.get("conditions"))
        description = request.POST.get("description")
        # annotation_json = request.POST.get("annotation_json")
        # viewport_json = request.POST.get("viewport_json")
        # current_image_id_index = request.POST.get("current_image_id_index")
        annotation_json_3d = request.POST.get("annotation_json_3d")
        viewer_state_3d = request.POST.get("viewer_state_3d")

        series = Series.objects.get(pk=series_id)
        findings = Finding.objects.filter(pk__in=findings_ids)
        structures = Structure.objects.filter(pk__in=structure_ids)
        conditions = Condition.objects.filter(pk__in=condition_ids)

        if series_finding_id > 0:
            sf = get_object_or_404(SeriesFinding, pk=series_finding_id)
            sf.series = series
            sf.description = description
            # sf.annotation_json = annotation_json
            # sf.viewport_json = viewport_json
        else:
            sf = SeriesFinding.objects.create(
                series=series,
                description=description,
                # annotation_json=annotation_json,
                # viewport_json=viewport_json,
            )

        sf.findings.set(findings)
        sf.structures.set(structures)
        sf.conditions.set(conditions)
        # sf.current_image_id_index = current_image_id_index
        sf.annotation_json_3d = annotation_json_3d
        sf.viewer_state_3d = viewer_state_3d
        sf.save()
        return JsonResponse({"success": True})

    return JsonResponse({"success": False})
    return render(request, "create_post.html", {"posts": posts})


class ConditionAutocomplete(autocomplete.Select2QuerySetView):
    def get_queryset(self):
        # Don't forget to filter out results depending on the visitor !
        if not self.request.user.is_authenticated:
            return Condition.objects.none()

        qs = Condition.objects.all()

        if self.q:
            # This raises a fielderror which breaks creating a new item if not caught
            try:
                qs = qs.filter(name__icontains=self.q)
            except FieldError:
                return Condition.objects.none()

        return qs


class ProcedureAutocomplete(autocomplete.Select2QuerySetView):
    def get_queryset(self):
        if not self.request.user.is_authenticated:
            return Procedure.objects.none()

        qs = Procedure.objects.all()

        if self.q:
            try:
                qs = qs.filter(name__icontains=self.q)
            except FieldError:
                return Procedure.objects.none()

        return qs


class CaseCollectionPrerequisiteAutocomplete(autocomplete.Select2QuerySetView):
    def get_queryset(self):
        if not self.request.user.is_authenticated:
            return CaseCollection.objects.none()

        qs = (
            CaseCollection.objects.filter(author=self.request.user)
            .distinct()
            .order_by("name")
        )

        if self.q:
            try:
                qs = qs.filter(name__icontains=self.q)
            except FieldError:
                return CaseCollection.objects.none()

        return qs


class ConditionAutocompleteRCRCurriculum(autocomplete.Select2QuerySetView):
    def get_queryset(self):
        # Don't forget to filter out results depending on the visitor !
        if not self.request.user.is_authenticated:
            return Condition.objects.none()

        qs = Condition.objects.filter(rcr_curriculum=True)

        if self.q:
            # This raises a fielderror which breaks creating a new item if not caught
            try:
                qs = qs.filter(name__icontains=self.q, rcr_curriculum=True)
            except FieldError:
                return Condition.objects.none()

        return qs


class PresentationAutocomplete(autocomplete.Select2QuerySetView):
    def get_queryset(self):
        # Don't forget to filter out results depending on the visitor !
        if not self.request.user.is_authenticated:
            return Presentation.objects.none()

        qs = Presentation.objects.all()

        if self.q:
            # This raises a fielderror which breaks creating a new item if not caught
            try:
                qs = qs.filter(name__icontains=self.q)
            except FieldError:
                return Presentation.objects.none()

        return qs


class FindingAutocomplete(autocomplete.Select2QuerySetView):
    def get_queryset(self):
        # Don't forget to filter out results depending on the visitor !
        if not self.request.user.is_authenticated:
            return Finding.objects.none()

        qs = Finding.objects.all()

        if self.q:
            # This raises a fielderror which breaks creating a new item if not caught
            try:
                qs = qs.filter(name__icontains=self.q)
            except FieldError:
                return Finding.objects.none()

        return qs


class StructureAutocomplete(autocomplete.Select2QuerySetView):
    def get_queryset(self):
        # Don't forget to filter out results depending on the visitor !
        if not self.request.user.is_authenticated:
            return Structure.objects.none()

        qs = Structure.objects.all()

        if self.q:
            # This raises a fielderror which breaks creating a new item if not caught
            try:
                qs = qs.filter(name__icontains=self.q)
            except FieldError:
                return Structure.objects.none()

        return qs

    def has_add_permission(self, request):
        """Allow any authenticated user to create new Structure entries.

        By default DAL requires the user to have the model add permission.
        For Anatomy question creation we want any registered user to be
        able to create structures, so relax that check to only require
        authentication.
        """
        try:
            auth = request.user.is_authenticated
        except Exception:
            auth = False

        return bool(auth)


class SubspecialtyAutocomplete(autocomplete.Select2QuerySetView):
    def get_queryset(self):
        # Don't forget to filter out results depending on the visitor !
        if not self.request.user.is_authenticated:
            return Subspecialty.objects.none()

        qs = Subspecialty.objects.all()

        if self.q:
            # This raises a fielderror which breaks creating a new item if not caught
            try:
                qs = qs.filter(name__icontains=self.q)
            except FieldError:
                return Subspecialty.objects.none()

        return qs


class SeriesAutocomplete(autocomplete.Select2QuerySetView):
    def get_queryset(self):
        if not self.request.user.is_authenticated:
            return Series.objects.none()

        qs = Series.objects.all().select_related("modality")

        is_atlas_editor = (
            self.request.user.groups.filter(name="atlas_editor").exists()
            or self.request.user.is_superuser
        )
        if not is_atlas_editor:
            qs = qs.filter(author__id=self.request.user.id)

        if self.q:
            q_filter = Q(description__icontains=self.q) | Q(info__icontains=self.q)
            if self.q.isdigit():
                q_filter |= Q(pk=int(self.q))
            qs = qs.filter(q_filter)

        return qs

    def get_result_label(self, result):
        return f"Series {result.pk}: {result.description or ''} ({result.modality or ''})"


@login_required
# @user_is_atlas_editor
def categories_list(request):
    # Allow an optional global search across category models via GET `q`.
    query = request.GET.get("q", "").strip()
    # Optional `types` GET parameter (can be repeated) to restrict which category
    # models to search. Expected values: 'conditions','findings','structures',
    # 'subspecialties','presentations'. If omitted, search all.
    types = request.GET.getlist("types")

    results = None
    if query:
        from .models import (
            Condition,
            Finding,
            Structure,
            Subspecialty,
            Presentation,
            Procedure,
        )

        # Helper to conditionally populate each result list only when the
        # corresponding type is requested (or when no types filter provided).
        def include(name: str) -> bool:
            return not types or name in types

        results = {
            "conditions": Condition.objects.filter(name__icontains=query).order_by(
                "name"
            )
            if include("conditions")
            else (),
            "findings": Finding.objects.filter(name__icontains=query).order_by("name")
            if include("findings")
            else (),
            "structures": Structure.objects.filter(name__icontains=query).order_by(
                "name"
            )
            if include("structures")
            else (),
            "subspecialties": Subspecialty.objects.filter(
                name__icontains=query
            ).order_by("name")
            if include("subspecialties")
            else (),
            "presentations": Presentation.objects.filter(
                name__icontains=query
            ).order_by("name")
            if include("presentations")
            else (),
            "procedures": Procedure.objects.filter(name__icontains=query).order_by(
                "name"
            )
            if include("procedures")
            else (),
        }

    return render(
        request, "atlas/categories_list.html", {"query": query, "results": results}
    )


@login_required
def categories_search_partial(request):
    """Return only the search results partial for HTMX requests.

    Expects GET param `q`.
    """
    query = request.GET.get("q", "").strip()
    types = request.GET.getlist("types")
    results = {
        "conditions": (),
        "findings": (),
        "structures": (),
        "subspecialties": (),
        "presentations": (),
        "procedures": (),
    }
    if query:
        from .models import (
            Condition,
            Finding,
            Structure,
            Subspecialty,
            Presentation,
            Procedure,
        )

        def include(name: str) -> bool:
            return not types or name in types

        results = {
            "conditions": Condition.objects.filter(name__icontains=query).order_by(
                "name"
            )
            if include("conditions")
            else (),
            "findings": Finding.objects.filter(name__icontains=query).order_by("name")
            if include("findings")
            else (),
            "structures": Structure.objects.filter(name__icontains=query).order_by(
                "name"
            )
            if include("structures")
            else (),
            "subspecialties": Subspecialty.objects.filter(
                name__icontains=query
            ).order_by("name")
            if include("subspecialties")
            else (),
            "presentations": Presentation.objects.filter(
                name__icontains=query
            ).order_by("name")
            if include("presentations")
            else (),
            "procedures": Procedure.objects.filter(name__icontains=query).order_by(
                "name"
            )
            if include("procedures")
            else (),
        }

    return render(
        request,
        "atlas/_categories_search_results.html",
        {"query": query, "results": results},
    )


# def collection_view(request):
#    collections = CaseCollection.objects.all()
#
#    return render(
#        request, "atlas/collection_view.html", {"collections": collections}
#    )
class CollectionView(LoginRequiredMixin, SingleTableMixin, FilterView):
    model = CaseCollection
    table_class = CaseCollectionTable
    template_name = "atlas/collection_view.html"

    filterset_class = CaseCollectionFilter


@user_is_collection_author_or_atlas_editor
def collection_viva(request, pk):
    collection = get_object_or_404(CaseCollection, pk=pk)

    # cases = (
    #    collection.cases.all()
    #    .order_by("casedetail__sort_order")
    #    .prefetch_related(
    #        "series",
    #        "series__images",
    #        "series__contrast",
    #        "series__plane",
    #        "series__examination",
    #    )
    # )

    # Prefetch and annotate aggressively to avoid N+1 queries in the viva view.
    # - Prefetch `case__series` using the ordering from SeriesDetail
    # - Prefetch only non-removed images for those series (ordered by position)
    # - Prefetch case resources and display sets
    from django.db.models import Prefetch as _Prefetch

    series_images_qs = SeriesImage.objects.filter(removed=False).order_by("position")
    series_qs = Series.objects.order_by("seriesdetail__sort_order").prefetch_related(
        _Prefetch("images", queryset=series_images_qs)
    )

    casedetails = (
        CaseDetail.objects.filter(collection=collection)
        .select_related("case")
        .order_by("sort_order")
        .prefetch_related(
            _Prefetch("case__series", queryset=series_qs),
            _Prefetch("case__series__modality"),
            _Prefetch("case__series__examination"),
            _Prefetch("case__series__plane"),
            _Prefetch("case__series__contrast"),
            _Prefetch("case__caseresource_set"),
            _Prefetch("case__display_sets"),
        )
        .annotate(series_count=Count("case__seriesdetail"))
    )

    return render(
        request,
        "atlas/collection_viva.html",
        {"collection": collection, "casedetails": casedetails, "can_edit": True},
    )


@user_is_collection_author_or_atlas_editor
def collection_viva_local(request, pk):
    """
    Local-only viva controller view.

    Scope:
    - Mirrors `collection_viva` functionality for case/series/displayset loading.
    - Uses only the embedded local DV3D viewer workflow.
    - Keeps the original viva route intact for side-by-side rollout.
    """
    collection = get_object_or_404(CaseCollection, pk=pk)

    from django.db.models import Prefetch as _Prefetch

    series_images_qs = (
        SeriesImage.objects.filter(removed=False)
        .only("id", "series_id", "image", "removed", "position")
        .order_by("position")
    )
    series_qs = (
        Series.objects.select_related("modality", "examination", "plane", "contrast")
        .order_by("seriesdetail__sort_order")
        .prefetch_related(
            _Prefetch("images", queryset=series_images_qs, to_attr="prefetched_images")
        )
    )

    casedetails = list(
        CaseDetail.objects.filter(collection=collection)
        .select_related("case")
        .order_by("sort_order")
        .prefetch_related(
            _Prefetch("case__series", queryset=series_qs, to_attr="prefetched_series"),
            _Prefetch(
                "case__caseresource_set",
                queryset=CaseResource.objects.select_related("resource"),
                to_attr="prefetched_case_resources",
            ),
            _Prefetch(
                "case__display_sets",
                queryset=CaseDisplaySet.objects.all(),
                to_attr="prefetched_display_sets",
            ),
        )
        .annotate(series_count=Count("case__seriesdetail"))
    )

    for casedetail in casedetails:
        case = casedetail.case
        prefetched_series = getattr(case, "prefetched_series", [])
        visible_prefetched_series = []
        images_nested = []
        for series in prefetched_series:
            if (
                casedetail.get_series_visibility_for(series, review_mode=False)
                == CaseDetail.SeriesVisibility.HIDDEN
            ):
                continue
            visible_prefetched_series.append(series)
            prefetched_images = getattr(series, "prefetched_images", [])
            images_nested.append(
                [
                    img.image.url
                    for img in prefetched_images
                    if not getattr(img, "removed", False)
                    and getattr(img, "image", None)
                ]
            )

        case.viva_details_json = json.dumps(
            {
                "title": case.title,
                "description": case.description,
                "history": case.history,
                "discussion": case.discussion,
                "report": case.report,
            }
        )
        case.visible_prefetched_series = visible_prefetched_series
        case.series_images_nested_json = json.dumps(images_nested)

    return render(
        request,
        "atlas/collection_viva_local.html",
        {"collection": collection, "casedetails": casedetails, "can_edit": True},
    )


@user_is_collection_author_or_atlas_editor
def collection_viva_case(request, pk, case_number):
    collection = get_object_or_404(CaseCollection, pk=pk)

    # cases = collection.cases.all().order_by("casedetail__sort_order")
    current_case = collection.get_case_by_index(case_number)

    return render(
        request,
        "atlas/collection_viva_case.html",
        {"collection": collection, "current_case": current_case},
    )


@user_is_collection_author_or_atlas_editor
def collection_viva_series(request, pk, series_id):
    collection = get_object_or_404(CaseCollection, pk=pk)

    series = get_object_or_404(Series, pk=series_id)

    return render(
        request,
        "atlas/collection_viva_series.html",
        {"collection": collection, "current_series": series},
    )


@user_is_collection_author_or_atlas_editor
def collection_detail(request, pk):
    collection = get_object_or_404(CaseCollection, pk=pk)
    collection_content_type = ContentType.objects.get_for_model(collection)
    messaging_enabled = _collection_case_query_messaging_enabled(collection)

    # cases = collection.cases.all().order_by("casedetail__sort_order")
    casedetails = (
        CaseDetail.objects.filter(collection=collection)
        .select_related("case")
        .annotate(series_count=Count("case__seriesdetail"))
        .order_by("sort_order")
    )

    if messaging_enabled:
        outstanding_messages = CaseReviewMessage.objects.filter(
            cid_user_exam__content_type=collection_content_type,
            cid_user_exam__object_id=collection.pk,
            requires_acknowledgement=True,
            acknowledged_at__isnull=True,
        )
        total_outstanding_messages = outstanding_messages.count()

        user_message_rows = list(
            collection.get_user_exams()
            .select_related("user_user")
            .annotate(
                outstanding_message_count=Count(
                    "case_review_messages",
                    filter=Q(
                        case_review_messages__requires_acknowledgement=True,
                        case_review_messages__acknowledged_at__isnull=True,
                    ),
                    distinct=True,
                ),
                total_review_message_count=Count("case_review_messages", distinct=True),
            )
            .filter(
                Q(outstanding_message_count__gt=0) | Q(total_review_message_count__gt=0)
            )
            .order_by(
                "-outstanding_message_count",
                "-total_review_message_count",
                "-start_time",
            )
        )

        cid_message_rows = list(
            collection.get_cid_exams()
            .select_related("cid_user")
            .annotate(
                outstanding_message_count=Count(
                    "case_review_messages",
                    filter=Q(
                        case_review_messages__requires_acknowledgement=True,
                        case_review_messages__acknowledged_at__isnull=True,
                    ),
                    distinct=True,
                ),
                total_review_message_count=Count("case_review_messages", distinct=True),
            )
            .filter(
                Q(outstanding_message_count__gt=0) | Q(total_review_message_count__gt=0)
            )
            .order_by(
                "-outstanding_message_count",
                "-total_review_message_count",
                "-start_time",
            )
        )
    else:
        total_outstanding_messages = 0
        user_message_rows = []
        cid_message_rows = []

    return render(
        request,
        "atlas/collection_detail.html",
        {
            "collection": collection,
            "casesdetails": casedetails,
            "can_edit": True,
            "exam": collection,
            "total_outstanding_messages": total_outstanding_messages,
            "user_message_rows": user_message_rows,
            "cid_message_rows": cid_message_rows,
        },
    )


@user_is_collection_author_or_atlas_editor
def collection_history(request, exam_id: int):
    """An overview page that shows who has attempted / finised the collection"""
    collection = get_object_or_404(CaseCollection, pk=exam_id)

    casedetails = list(
        CaseDetail.objects.filter(collection=collection).order_by("sort_order")
    )

    if _collection_case_query_messaging_enabled(collection):
        userexams = list(
            collection.get_user_exams()
            .select_related("user_user")
            .annotate(
                outstanding_feedback_count=Count(
                    "case_review_messages",
                    filter=Q(
                        case_review_messages__requires_acknowledgement=True,
                        case_review_messages__acknowledged_at__isnull=True,
                    ),
                    distinct=True,
                ),
                total_review_message_count=Count("case_review_messages", distinct=True),
            )
            .order_by("-outstanding_feedback_count", "completed", "-start_time")
        )
        cidexams = list(
            collection.get_cid_exams()
            .select_related("cid_user")
            .annotate(
                outstanding_feedback_count=Count(
                    "case_review_messages",
                    filter=Q(
                        case_review_messages__requires_acknowledgement=True,
                        case_review_messages__acknowledged_at__isnull=True,
                    ),
                    distinct=True,
                ),
                total_review_message_count=Count("case_review_messages", distinct=True),
            )
            .order_by("-outstanding_feedback_count", "completed", "-start_time")
        )
    else:
        userexams = list(
            collection.get_user_exams()
            .select_related("user_user")
            .order_by("completed", "-start_time")
        )
        cidexams = list(
            collection.get_cid_exams()
            .select_related("cid_user")
            .order_by("completed", "-start_time")
        )
        for userexam in userexams:
            userexam.outstanding_feedback_count = 0
            userexam.total_review_message_count = 0
        for cidexam in cidexams:
            cidexam.outstanding_feedback_count = 0
            cidexam.total_review_message_count = 0

    total_outstanding_messages = sum(
        item.outstanding_feedback_count for item in userexams
    ) + sum(item.outstanding_feedback_count for item in cidexams)
    completed_attempt_count = sum(1 for item in userexams if item.completed) + sum(
        1 for item in cidexams if item.completed
    )
    total_attempt_count = len(userexams) + len(cidexams)

    return render(
        request,
        "atlas/collection_history.html",
        {
            "collection": collection,
            "casesdetails": casedetails,
            "can_edit": True,
            "userexams": userexams,
            "cidexams": cidexams,
            "total_cases": len(casedetails),
            "total_attempt_count": total_attempt_count,
            "completed_attempt_count": completed_attempt_count,
            "total_outstanding_messages": total_outstanding_messages,
        },
    )


def _get_case_review_stats_for_exam(
    *,
    cid_user_exam: CidUserExam,
    casedetails: list[CaseDetail] | QuerySet[CaseDetail],
    messaging_enabled: bool = True,
) -> tuple[dict[int, dict[str, int | bool]], int, int]:
    case_ids = [casedetail.case_id for casedetail in casedetails]
    stats_by_case = {
        case_id: {
            "message_count": 0,
            "outstanding_feedback_count": 0,
            "outstanding_query_count": 0,
            "outstanding_total_count": 0,
            "has_messages": False,
            "has_outstanding_feedback": False,
            "has_outstanding_query": False,
            "has_outstanding_any": False,
        }
        for case_id in case_ids
    }

    if not messaging_enabled:
        return stats_by_case, 0, 0

    message_rows = (
        CaseReviewMessage.objects.filter(
            cid_user_exam=cid_user_exam, case_id__in=case_ids
        )
        .values("case_id")
        .annotate(
            message_count=Count("id"),
            outstanding_total_count=Count(
                "id",
                filter=Q(
                    requires_acknowledgement=True,
                    acknowledged_at__isnull=True,
                ),
            ),
            outstanding_feedback_count=Count(
                "id",
                filter=Q(
                    requires_acknowledgement=True,
                    acknowledged_at__isnull=True,
                    message_type=CaseReviewMessage.MessageType.FEEDBACK,
                ),
            ),
            outstanding_query_count=Count(
                "id",
                filter=Q(
                    requires_acknowledgement=True,
                    acknowledged_at__isnull=True,
                    message_type=CaseReviewMessage.MessageType.QUERY,
                ),
            ),
        )
    )

    outstanding_case_count = 0
    total_outstanding_feedback = 0
    for row in message_rows:
        outstanding_total_count = row["outstanding_total_count"]
        outstanding_feedback_count = row["outstanding_feedback_count"]
        outstanding_query_count = row["outstanding_query_count"]
        stats_by_case[row["case_id"]] = {
            "message_count": row["message_count"],
            "outstanding_feedback_count": outstanding_feedback_count,
            "outstanding_query_count": outstanding_query_count,
            "outstanding_total_count": outstanding_total_count,
            "has_messages": row["message_count"] > 0,
            "has_outstanding_feedback": outstanding_feedback_count > 0,
            "has_outstanding_query": outstanding_query_count > 0,
            "has_outstanding_any": outstanding_total_count > 0,
        }
        if outstanding_total_count:
            outstanding_case_count += 1
            total_outstanding_feedback += outstanding_total_count

    return stats_by_case, outstanding_case_count, total_outstanding_feedback


def _build_collection_feedback_rows(
    *,
    collection: CaseCollection,
    cid_user_exam: CidUserExam,
    casedetails: list[CaseDetail] | QuerySet[CaseDetail],
    cid: int | None = None,
) -> tuple[list[dict[str, Any]], int, int]:
    stats_by_case, outstanding_case_count, total_outstanding_feedback = (
        _get_case_review_stats_for_exam(
            cid_user_exam=cid_user_exam,
            casedetails=casedetails,
            messaging_enabled=_collection_case_query_messaging_enabled(collection),
        )
    )

    rows = []
    for casedetail in casedetails:
        case_stats = stats_by_case.get(casedetail.case_id, {})
        rows.append(
            {
                "casedetail": casedetail,
                "stats": case_stats,
                "thread_url": reverse(
                    "atlas:collection_case_review_thread",
                    kwargs={
                        "exam_id": collection.pk,
                        "cid_user_exam_id": cid_user_exam.pk,
                        "case_id": casedetail.case_id,
                    },
                ),
                "cid": cid,
            }
        )

    return rows, outstanding_case_count, total_outstanding_feedback


@user_is_collection_author_or_atlas_editor
def collection_history_user(request, exam_id: int, user_pk: int):
    collection = get_object_or_404(CaseCollection, pk=exam_id)

    user = get_object_or_404(User, pk=user_pk)
    cid_user_exam = collection.get_or_create_cid_user_exam(user_user=user)

    casedetails = CaseDetail.objects.filter(
        collection=collection,
    ).order_by("sort_order")

    reviews = cid_user_exam.selfreview_set.filter(
        case__in=[cd.case for cd in casedetails]
    ).order_by("-review_update_date")
    reviews_by_case = {}
    for review in reviews:
        reviews_by_case.setdefault(review.case_id, []).append(review)

    history_rows = []
    answer_count = 0
    case_review_stats, _, _ = _get_case_review_stats_for_exam(
        cid_user_exam=cid_user_exam,
        casedetails=casedetails,
        messaging_enabled=_collection_case_query_messaging_enabled(collection),
    )
    for casedetail in casedetails:
        user_answer = casedetail.get_user_answers(user)
        if user_answer and (user_answer.answer or user_answer.json_answer):
            answer_count += 1

        history_rows.append(
            {
                "casedetail": casedetail,
                "user_answer": user_answer,
                "self_reviews": reviews_by_case.get(casedetail.case_id, []),
                "case_review_stats": case_review_stats.get(casedetail.case_id, {}),
                "thread_url": reverse(
                    "atlas:collection_case_review_thread",
                    kwargs={
                        "exam_id": collection.pk,
                        "cid_user_exam_id": cid_user_exam.pk,
                        "case_id": casedetail.case_id,
                    },
                ),
            }
        )

    return render(
        request,
        "atlas/collection_history_user.html",
        {
            "collection": collection,
            "user": user,
            "cid_user_exam": cid_user_exam,
            "history_rows": history_rows,
            "answer_count": answer_count,
            "collection_length": len(history_rows),
        },
    )


@user_is_collection_author_or_atlas_editor
def collection_history_ciduser(request, exam_id: int, cid: int):
    collection = get_object_or_404(CaseCollection, pk=exam_id)
    cid_user_exam = collection.get_or_create_cid_user_exam(cid=cid)

    casedetails = CaseDetail.objects.filter(
        collection=collection,
    ).order_by("sort_order")

    reviews = cid_user_exam.selfreview_set.filter(
        case__in=[cd.case for cd in casedetails]
    ).order_by("-review_update_date")
    reviews_by_case = {}
    for review in reviews:
        reviews_by_case.setdefault(review.case_id, []).append(review)

    history_rows = []
    answer_count = 0
    case_review_stats, _, _ = _get_case_review_stats_for_exam(
        cid_user_exam=cid_user_exam,
        casedetails=casedetails,
        messaging_enabled=_collection_case_query_messaging_enabled(collection),
    )
    for casedetail in casedetails:
        user_answer = casedetail.get_cid_answers(cid)
        if user_answer and (user_answer.answer or user_answer.json_answer):
            answer_count += 1

        history_rows.append(
            {
                "casedetail": casedetail,
                "user_answer": user_answer,
                "self_reviews": reviews_by_case.get(casedetail.case_id, []),
                "case_review_stats": case_review_stats.get(casedetail.case_id, {}),
                "thread_url": reverse(
                    "atlas:collection_case_review_thread",
                    kwargs={
                        "exam_id": collection.pk,
                        "cid_user_exam_id": cid_user_exam.pk,
                        "case_id": casedetail.case_id,
                    },
                ),
                "cid": cid,
            }
        )

    return render(
        request,
        "atlas/collection_history_user.html",
        {
            "collection": collection,
            "user": cid,
            "cid_user_exam": cid_user_exam,
            "history_rows": history_rows,
            "answer_count": answer_count,
            "collection_length": len(history_rows),
            "cid": cid,
        },
    )


@user_is_collection_author_or_atlas_editor
def collection_user_messages(request, exam_id: int, user_pk: int):
    """Supervisor page showing case conversations for one user attempt.

    Outstanding items are prioritized. Full historical threads can be loaded on
    demand from the same page.
    """

    collection = get_object_or_404(CaseCollection, pk=exam_id)
    _enforce_collection_case_query_messaging_enabled(collection)
    user = get_object_or_404(User, pk=user_pk)
    cid_user_exam = collection.get_or_create_cid_user_exam(user_user=user)

    casedetails = list(
        CaseDetail.objects.filter(collection=collection)
        .select_related("case")
        .order_by("sort_order")
    )
    feedback_rows, _, _ = _build_collection_feedback_rows(
        collection=collection,
        cid_user_exam=cid_user_exam,
        casedetails=casedetails,
    )

    outstanding_rows = [
        row for row in feedback_rows if row["stats"].get("has_outstanding_any")
    ]
    history_rows = [
        row
        for row in feedback_rows
        if row["stats"].get("has_messages")
        and not row["stats"].get("has_outstanding_any")
    ]
    total_outstanding_messages = sum(
        row["stats"].get("outstanding_total_count", 0) for row in feedback_rows
    )
    show_all = request.GET.get("show") == "all"

    return render(
        request,
        "atlas/collection_user_messages.html",
        {
            "collection": collection,
            "target_label": user.get_full_name() or user.username,
            "target_user": user,
            "cid_user_exam": cid_user_exam,
            "outstanding_rows": outstanding_rows,
            "history_rows": history_rows,
            "total_outstanding_messages": total_outstanding_messages,
            "show_all": show_all,
            "cid": None,
            "passcode": None,
        },
    )


@user_is_collection_author_or_atlas_editor
def collection_cid_messages(request, exam_id: int, cid: int):
    """Supervisor page showing case conversations for one CID attempt."""

    collection = get_object_or_404(CaseCollection, pk=exam_id)
    _enforce_collection_case_query_messaging_enabled(collection)
    cid_user_exam = collection.get_or_create_cid_user_exam(cid=cid)

    casedetails = list(
        CaseDetail.objects.filter(collection=collection)
        .select_related("case")
        .order_by("sort_order")
    )
    feedback_rows, _, _ = _build_collection_feedback_rows(
        collection=collection,
        cid_user_exam=cid_user_exam,
        casedetails=casedetails,
        cid=cid,
    )

    outstanding_rows = [
        row for row in feedback_rows if row["stats"].get("has_outstanding_any")
    ]
    history_rows = [
        row
        for row in feedback_rows
        if row["stats"].get("has_messages")
        and not row["stats"].get("has_outstanding_any")
    ]
    total_outstanding_messages = sum(
        row["stats"].get("outstanding_total_count", 0) for row in feedback_rows
    )
    show_all = request.GET.get("show") == "all"

    return render(
        request,
        "atlas/collection_user_messages.html",
        {
            "collection": collection,
            "target_label": f"CID {cid}",
            "target_cid": cid,
            "cid_user_exam": cid_user_exam,
            "outstanding_rows": outstanding_rows,
            "history_rows": history_rows,
            "total_outstanding_messages": total_outstanding_messages,
            "show_all": show_all,
            "cid": cid,
            "passcode": None,
        },
    )


@login_required
def user_messages_inbox(request):
    """Generic message inbox for authenticated users.

    Shows outstanding acknowledgement items and allows loading full message
    history across all accessible collection attempts.
    """

    user = request.user
    collection_content_type = ContentType.objects.get_for_model(CaseCollection)

    is_global_moderator = (
        user.is_superuser or user.groups.filter(name="atlas_editor").exists()
    )

    base_attempts = CidUserExam.objects.filter(
        content_type=collection_content_type
    ).select_related("user_user", "cid_user")
    learner_attempts = list(base_attempts.filter(user_user=user))

    moderator_attempts = []
    if is_global_moderator:
        moderator_attempts = list(base_attempts)
    else:
        authored_ids = list(
            CaseCollection.objects.filter(author=user).values_list("pk", flat=True)
        )
        if authored_ids:
            moderator_attempts = list(base_attempts.filter(object_id__in=authored_ids))

    attempts_map = {}
    for attempt in learner_attempts:
        attempts_map[attempt.pk] = {"attempt": attempt, "viewer_role": "learner"}
    for attempt in moderator_attempts:
        attempts_map[attempt.pk] = {"attempt": attempt, "viewer_role": "moderator"}

    if not attempts_map:
        return render(
            request,
            "atlas/user_messages_inbox.html",
            {
                "inbox_rows": [],
                "total_outstanding": 0,
                "total_needs_your_ack": 0,
                "total_waiting_on_other": 0,
                "show_all": request.GET.get("show") == "all",
            },
        )

    attempts = [item["attempt"] for item in attempts_map.values()]
    collection_ids = sorted({attempt.object_id for attempt in attempts})

    collections_by_id = CaseCollection.objects.in_bulk(collection_ids)
    enabled_collection_ids = {
        collection_id
        for collection_id, collection in collections_by_id.items()
        if collection is not None
        and _collection_case_query_messaging_enabled(collection)
    }

    attempts = [
        attempt for attempt in attempts if attempt.object_id in enabled_collection_ids
    ]
    if not attempts:
        return render(
            request,
            "atlas/user_messages_inbox.html",
            {
                "inbox_rows": [],
                "total_outstanding": 0,
                "total_needs_your_ack": 0,
                "total_waiting_on_other": 0,
                "show_all": request.GET.get("show") == "all",
            },
        )

    attempt_ids = [attempt.pk for attempt in attempts]
    collection_ids = sorted(enabled_collection_ids)

    casedetails_by_collection = defaultdict(list)
    all_case_ids = set()
    for casedetail in (
        CaseDetail.objects.filter(collection_id__in=collection_ids)
        .select_related("case")
        .order_by("collection_id", "sort_order")
    ):
        casedetails_by_collection[casedetail.collection_id].append(casedetail)
        all_case_ids.add(casedetail.case_id)

    message_stats_by_attempt_case = defaultdict(dict)
    if all_case_ids:
        for row in (
            CaseReviewMessage.objects.filter(
                cid_user_exam_id__in=attempt_ids,
                case_id__in=all_case_ids,
            )
            .values("cid_user_exam_id", "case_id")
            .annotate(
                message_count=Count("id"),
                outstanding_total_count=Count(
                    "id",
                    filter=Q(
                        requires_acknowledgement=True,
                        acknowledged_at__isnull=True,
                    ),
                ),
                outstanding_query_count=Count(
                    "id",
                    filter=Q(
                        requires_acknowledgement=True,
                        acknowledged_at__isnull=True,
                        message_type=CaseReviewMessage.MessageType.QUERY,
                    ),
                ),
            )
        ):
            outstanding_total_count = row["outstanding_total_count"]
            outstanding_query_count = row["outstanding_query_count"]
            message_stats_by_attempt_case[row["cid_user_exam_id"]][row["case_id"]] = {
                "message_count": row["message_count"],
                "outstanding_total_count": outstanding_total_count,
                "outstanding_query_count": outstanding_query_count,
                "outstanding_feedback_count": outstanding_total_count
                - outstanding_query_count,
                "has_messages": row["message_count"] > 0,
                "has_outstanding_any": outstanding_total_count > 0,
            }

    inbox_rows = []
    for item in attempts_map.values():
        attempt = item["attempt"]
        viewer_role = item["viewer_role"]
        collection = collections_by_id.get(attempt.object_id)
        if collection is None or not _collection_case_query_messaging_enabled(
            collection
        ):
            continue

        casedetails = casedetails_by_collection.get(collection.pk, [])
        if not casedetails:
            continue

        attempt_stats = message_stats_by_attempt_case.get(attempt.pk, {})
        feedback_rows = []
        for casedetail in casedetails:
            stats = attempt_stats.get(
                casedetail.case_id,
                {
                    "message_count": 0,
                    "outstanding_total_count": 0,
                    "outstanding_query_count": 0,
                    "outstanding_feedback_count": 0,
                    "has_messages": False,
                    "has_outstanding_any": False,
                },
            )

            outstanding_query_count = stats.get("outstanding_query_count", 0)
            outstanding_feedback_count = stats.get("outstanding_feedback_count", 0)
            if viewer_role == "moderator":
                needs_your_ack_count = outstanding_query_count
                waiting_on_other_count = outstanding_feedback_count
            else:
                needs_your_ack_count = outstanding_feedback_count
                waiting_on_other_count = outstanding_query_count

            enriched_stats = {
                **stats,
                "needs_your_ack_count": needs_your_ack_count,
                "waiting_on_other_count": waiting_on_other_count,
            }
            feedback_rows.append(
                {
                    "casedetail": casedetail,
                    "stats": enriched_stats,
                    "thread_url": reverse(
                        "atlas:collection_case_review_thread",
                        kwargs={
                            "exam_id": collection.pk,
                            "cid_user_exam_id": attempt.pk,
                            "case_id": casedetail.case_id,
                        },
                    ),
                    "cid": attempt.cid_user.cid if attempt.cid_user_id else None,
                }
            )

        outstanding_rows = [
            row for row in feedback_rows if row["stats"].get("has_outstanding_any")
        ]
        history_rows = [
            row
            for row in feedback_rows
            if row["stats"].get("has_messages")
            and not row["stats"].get("has_outstanding_any")
        ]

        if not outstanding_rows and not history_rows:
            continue

        target_label = attempt.get_user_name()
        if attempt.user_user_id:
            detail_url = reverse(
                "atlas:collection_user_messages",
                kwargs={"exam_id": collection.pk, "user_pk": attempt.user_user_id},
            )
        else:
            detail_url = reverse(
                "atlas:collection_cid_messages",
                kwargs={"exam_id": collection.pk, "cid": attempt.cid_user.cid},
            )

        outstanding_count = sum(
            row["stats"].get("outstanding_total_count", 0) for row in feedback_rows
        )
        needs_your_ack_count = sum(
            row["stats"].get("needs_your_ack_count", 0) for row in feedback_rows
        )
        waiting_on_other_count = sum(
            row["stats"].get("waiting_on_other_count", 0) for row in feedback_rows
        )
        inbox_rows.append(
            {
                "collection": collection,
                "attempt": attempt,
                "target_label": target_label,
                "viewer_role": viewer_role,
                "detail_url": detail_url,
                "outstanding_rows": outstanding_rows,
                "history_rows": history_rows,
                "outstanding_count": outstanding_count,
                "needs_your_ack_count": needs_your_ack_count,
                "waiting_on_other_count": waiting_on_other_count,
            }
        )

    inbox_rows.sort(
        key=lambda row: (
            row["outstanding_count"] > 0,
            row["outstanding_count"],
            len(row["history_rows"]),
        ),
        reverse=True,
    )

    total_outstanding = sum(row["outstanding_count"] for row in inbox_rows)
    total_needs_your_ack = sum(row["needs_your_ack_count"] for row in inbox_rows)
    total_waiting_on_other = sum(row["waiting_on_other_count"] for row in inbox_rows)
    show_all = request.GET.get("show") == "all"

    return render(
        request,
        "atlas/user_messages_inbox.html",
        {
            "inbox_rows": inbox_rows,
            "total_outstanding": total_outstanding,
            "total_needs_your_ack": total_needs_your_ack,
            "total_waiting_on_other": total_waiting_on_other,
            "show_all": show_all,
        },
    )


def _user_can_moderate_collection(collection: CaseCollection, user: User) -> bool:
    if not getattr(user, "is_authenticated", False):
        return False

    return (
        user.is_superuser
        or user.groups.filter(name="atlas_editor").exists()
        or collection.author.filter(pk=user.pk).exists()
    )


def _collection_case_query_messaging_enabled(collection: CaseCollection) -> bool:
    return bool(getattr(collection, "case_query_messaging_enabled", True))


def _enforce_collection_case_query_messaging_enabled(collection: CaseCollection):
    if not _collection_case_query_messaging_enabled(collection):
        raise Http404("Case query and messaging are disabled for this collection")


def _get_case_review_thread_context(
    *,
    collection: CaseCollection,
    cid_user_exam: CidUserExam,
    case: Case,
    request,
    cid: int | None = None,
    passcode: str | None = None,
):
    can_feedback = _user_can_moderate_collection(collection, request.user)
    can_query = False
    can_acknowledge_feedback = False
    can_acknowledge_query = False

    if not can_feedback:
        try:
            collection.check_user_can_take(cid, passcode, request.user)
            if cid_user_exam.user_user_id:
                can_query = (
                    request.user.is_authenticated
                    and cid_user_exam.user_user_id == request.user.id
                )
            elif cid_user_exam.cid_user_id and cid is not None:
                can_query = str(cid_user_exam.cid_user.cid) == str(cid)
            can_acknowledge_feedback = can_query
        except Exception:
            can_query = False
            can_acknowledge_feedback = False

    if can_feedback:
        can_acknowledge_query = True

    messages = list(
        CaseReviewMessage.objects.filter(cid_user_exam=cid_user_exam, case=case)
        .select_related("author")
        .order_by("created_at")
    )

    supervisor_form = None
    if can_feedback:
        supervisor_form = CaseReviewMessageForm(
            allow_ack=True,
            message_placeholder="Give case-specific feedback or reply to a learner query...",
            initial={"requires_acknowledgement": True},
        )

    query_form = None
    if can_query:
        query_form = CaseReviewMessageForm(
            allow_ack=False,
            message_placeholder="Ask a question or raise a query about this case...",
        )

    unacknowledged_count = sum(
        1
        for message in messages
        if message.requires_acknowledgement and not message.is_acknowledged
    )

    if can_feedback:
        pending_for_viewer_count = sum(
            1
            for message in messages
            if message.requires_acknowledgement
            and not message.is_acknowledged
            and message.message_type == CaseReviewMessage.MessageType.QUERY
        )
    else:
        pending_for_viewer_count = sum(
            1
            for message in messages
            if message.requires_acknowledgement
            and not message.is_acknowledged
            and message.message_type != CaseReviewMessage.MessageType.QUERY
        )

    return {
        "collection": collection,
        "cid_user_exam": cid_user_exam,
        "case": case,
        "messages": messages,
        "can_feedback": can_feedback,
        "can_query": can_query,
        "can_acknowledge_feedback": can_acknowledge_feedback,
        "can_acknowledge_query": can_acknowledge_query,
        "supervisor_form": supervisor_form,
        "query_form": query_form,
        "thread_url": reverse(
            "atlas:collection_case_review_thread",
            kwargs={
                "exam_id": collection.pk,
                "cid_user_exam_id": cid_user_exam.pk,
                "case_id": case.pk,
            },
        ),
        "thread_dom_id": f"case-review-thread-{cid_user_exam.pk}-{case.pk}",
        "cid": cid,
        "passcode": passcode,
        "unacknowledged_count": unacknowledged_count,
        "pending_for_viewer_count": pending_for_viewer_count,
    }


@require_http_methods(["GET", "POST"])
def collection_case_review_thread(
    request, exam_id: int, cid_user_exam_id: int, case_id: int
):
    """Render and mutate the per-case conversation thread for one learner attempt.

    The thread supports supervisor feedback/replies, learner queries, and learner
    acknowledgement of flagged feedback. All POSTs return the refreshed HTMX
    fragment for the same case thread.
    """

    collection = get_object_or_404(CaseCollection, pk=exam_id)
    _enforce_collection_case_query_messaging_enabled(collection)
    collection_content_type = ContentType.objects.get_for_model(collection)
    cid_user_exam = get_object_or_404(
        CidUserExam,
        pk=cid_user_exam_id,
        content_type=collection_content_type,
        object_id=collection.pk,
    )
    case = get_object_or_404(Case, pk=case_id)

    if not CaseDetail.objects.filter(collection=collection, case=case).exists():
        raise Http404("Case not found in collection")

    cid = request.POST.get("cid") or request.GET.get("cid")
    passcode = request.POST.get("passcode") or request.GET.get("passcode")

    can_feedback = _user_can_moderate_collection(collection, request.user)
    can_query = False
    if not can_feedback:
        # Check if request.user is the supervisor of the attempt owner and sharing is enabled
        is_supervisor = False
        if request.user.is_authenticated and cid_user_exam.user_user_id:
            try:
                sup = cid_user_exam.user_user.userprofile.supervisor
                if sup and sup.user_id == request.user.id:
                    if (
                        cid_user_exam.share_with_supervisor
                        or collection.results_supervisor_visible
                    ):
                        is_supervisor = True
            except Exception:
                pass

        if is_supervisor:
            can_feedback = True
        else:
            try:
                collection.check_user_can_take(cid, passcode, request.user)
                if cid_user_exam.user_user_id:
                    can_query = (
                        request.user.is_authenticated
                        and cid_user_exam.user_user_id == request.user.id
                    )
                elif cid_user_exam.cid_user_id and cid is not None:
                    can_query = str(cid_user_exam.cid_user.cid) == str(cid)
            except Exception:
                can_query = False

    if not (can_feedback or can_query):
        raise PermissionDenied

    if request.method == "POST":
        action = request.POST.get("action")

        if action == "add_message":
            if can_feedback:
                form = CaseReviewMessageForm(
                    request.POST,
                    allow_ack=True,
                    message_placeholder="Give case-specific feedback or reply to a learner query...",
                )
                message_type = request.POST.get(
                    "message_type", CaseReviewMessage.MessageType.FEEDBACK
                )
                if message_type not in {
                    CaseReviewMessage.MessageType.FEEDBACK,
                    CaseReviewMessage.MessageType.REPLY,
                }:
                    message_type = CaseReviewMessage.MessageType.FEEDBACK
            else:
                form = CaseReviewMessageForm(
                    request.POST,
                    allow_ack=False,
                    message_placeholder="Ask a question or raise a query about this case...",
                )
                message_type = CaseReviewMessage.MessageType.QUERY

            if form.is_valid():
                message = form.save(commit=False)
                message.cid_user_exam = cid_user_exam
                message.case = case
                if request.user.is_authenticated:
                    message.author = request.user
                    message.author_label = ""
                else:
                    message.author = None
                    message.author_label = (
                        f"CID {cid}" if cid is not None else "Learner"
                    )
                message.message_type = message_type
                if message_type == CaseReviewMessage.MessageType.QUERY:
                    # Learner queries must be acknowledged by a supervisor/author.
                    message.requires_acknowledgement = True
                elif message_type not in {
                    CaseReviewMessage.MessageType.FEEDBACK,
                    CaseReviewMessage.MessageType.REPLY,
                }:
                    message.requires_acknowledgement = False
                message.save()

        elif action == "acknowledge":
            message = get_object_or_404(
                CaseReviewMessage,
                pk=request.POST.get("message_id"),
                cid_user_exam=cid_user_exam,
                case=case,
            )

            if (
                can_feedback
                and message.message_type != CaseReviewMessage.MessageType.QUERY
            ):
                raise PermissionDenied
            if (
                can_query
                and message.message_type == CaseReviewMessage.MessageType.QUERY
            ):
                raise PermissionDenied
            if not (can_feedback or can_query):
                raise PermissionDenied

            if message.requires_acknowledgement:
                message.acknowledge()

    context = _get_case_review_thread_context(
        collection=collection,
        cid_user_exam=cid_user_exam,
        case=case,
        request=request,
        cid=int(cid) if cid and str(cid).isdigit() else None,
        passcode=passcode,
    )
    return render(request, "atlas/partials/_case_review_thread.html", context)


def collection_take_start(request, pk, cid=None, passcode=None):
    """The starting page for taking / reviewing a case collection

    Args:
        request (_type_): _description_
        pk (_type_): _description_

    Returns:
        _type_: _description_
    """
    collection = get_object_or_404(CaseCollection, pk=pk)
    try:
        collection.check_user_can_take(cid, passcode, request.user)
        valid_user = True
    except Http404:
        valid_user = False
    except collection.InactiveException as e:
        return HttpResponse(f"<h1>{str(e)}</h1>")
    except PrerequisiteRequired as e:
        # Show a friendly page linking to the required collection rather than 404
        prereq = getattr(e, "prereq", None)
        return render(
            request,
            "atlas/prerequisite_required.html",
            {"message": str(e), "prereq": prereq, "cid": cid, "passcode": passcode},
        )

    # Check if pre-survey is required and not yet taken
    response = check_survey_interception(request, collection, cid, passcode)
    if response:
        return response

    # valid_user = collection.check_logged_in_user(request)

    cid_exam = None
    if collection.collection_type == "REV" or valid_user:
        cid_exam = collection.get_cid_and_user_exams(user_user=request.user).first()

    # Build a lightweight summary of who results will be shared with.
    sharing_summary = _collection_sharing_summary(collection, request.user)

    template_variables = {
        "collection": collection,
        "valid_user": valid_user,
        "cid_exam": cid_exam,
        "sharing_summary": sharing_summary,
        "start_screen_resources": collection.start_screen_resources.all(),
    }

    match collection.collection_type:
        case x if x in ("REP", "QUE"):
            return render(
                request, "atlas/collection_take_start.html", template_variables
            )
        case "REV":
            return render(
                request, "atlas/collection_review_start.html", template_variables
            )
        case _:
            return HttpResponse(f"Unknown collection type {collection.collection_type}")


def _case_number_for_collection_and_caseid(collection_pk, case_id):
    """Helper: given a collection PK and a case PK, return the case index (case_number).

    Raises Http404 if the case is not found in the collection.
    """
    collection = get_object_or_404(CaseCollection, pk=collection_pk)
    try:
        # attempt to resolve the case object (may raise Http404)
        case_obj = get_case_for_case_detail(case_id)
    except Http404:
        raise
    try:
        return collection.get_index_of_case(case_obj)
    except ValueError:
        raise Http404(f"Case {case_id} not in collection {collection_pk}")


@login_required
def redirect_collection_case_details_by_id(request, exam_id, case_id):
    case_number = _case_number_for_collection_and_caseid(exam_id, case_id)
    return redirect(
        "atlas:collection_case_details", exam_id=exam_id, case_number=case_number
    )


@login_required
def redirect_collection_case_questions_by_id(request, exam_id, case_id):
    case_number = _case_number_for_collection_and_caseid(exam_id, case_id)
    return redirect(
        "atlas:collection_case_questions", exam_id=exam_id, case_number=case_number
    )


@login_required
def redirect_collection_case_priors_by_id(request, exam_id, case_id):
    case_number = _case_number_for_collection_and_caseid(exam_id, case_id)
    return redirect(
        "atlas:collection_case_priors", exam_id=exam_id, case_number=case_number
    )


@login_required
def redirect_collection_case_dicom_json_by_id(request, exam_id, case_id):
    case_number = _case_number_for_collection_and_caseid(exam_id, case_id)
    return redirect(
        "atlas:collection_case_dicom_json", exam_id=exam_id, case_number=case_number
    )


@login_required
def redirect_collection_case_dicom_json_review_by_id(request, exam_id, case_id):
    case_number = _case_number_for_collection_and_caseid(exam_id, case_id)
    return redirect(
        "atlas:collection_case_dicom_json_review",
        exam_id=exam_id,
        case_number=case_number,
    )


@login_required
def redirect_collection_case_view_by_id(request, pk, case_id):
    case_number = _case_number_for_collection_and_caseid(pk, case_id)
    return redirect("atlas:collection_case_view", pk=pk, case_number=case_number)


@login_required
def redirect_collection_case_displaysetup_by_id(request, collection_id, case_id):
    case_number = _case_number_for_collection_and_caseid(collection_id, case_id)
    return redirect(
        "atlas:collection_case_displaysetup",
        collection_id=collection_id,
        case_number=case_number,
    )


@login_required
def redirect_collection_case_view_take_by_id(request, pk, case_id, cid, passcode):
    case_number = _case_number_for_collection_and_caseid(pk, case_id)
    return redirect(
        "atlas:collection_case_view_take",
        pk=pk,
        case_number=case_number,
        cid=cid,
        passcode=passcode,
    )


@login_required
def redirect_collection_case_view_take_user_by_id(request, pk, case_id):
    case_number = _case_number_for_collection_and_caseid(pk, case_id)
    return redirect(
        "atlas:collection_case_view_take_user", pk=pk, case_number=case_number
    )


@login_required
def redirect_collection_case_view_take_user_answers_by_id(request, pk, case_id):
    case_number = _case_number_for_collection_and_caseid(pk, case_id)
    return redirect(
        "atlas:collection_case_view_take_user_answers", pk=pk, case_number=case_number
    )


@user_is_collection_author_or_atlas_editor
def collection_case_priors(request, exam_id, case_number):
    collection = get_object_or_404(CaseCollection, pk=exam_id)
    try:
        case_obj = collection.get_case_by_index(case_number)
        casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
    except Exception:
        raise Http404("Case not found in collection")

    def _render_prior_card_response(
        prior_case, added, relation, visibility, relation_type, error=None
    ):
        html = render_to_string(
            "atlas/partials/_prior_card.html",
            {
                "case": prior_case,
                "added": added,
                "relation": relation,
                "visibility": visibility,
                "relation_type": relation_type,
                "casedetail": casedetail,
                "collection": collection,
                "case_number": case_number,
                "error": error,
            },
            request=request,
        )
        return HttpResponse(html)

    if request.htmx:
        # Ensure we can render the updated single-card partial and return it so HTMX
        # can swap the card on the client side.

        if "remove" in request.POST:
            prior_pk = request.POST["remove"]
            prior_case = Case.objects.filter(pk=prior_pk).first()
            if prior_case is None:
                return HttpResponse("Prior case not found", status=404)

            try:
                p = CasePrior.objects.get(casedetail=casedetail, prior_case=prior_pk)
                p.delete()
            except CasePrior.DoesNotExist:
                # Already removed; continue and render the not-added card
                pass

            added = False
            relation = ""
            visibility = "AL"

            if prior_case in casedetail.case.get_all_prior_cases():
                rel_type = CasePrior.RelationType.PRIOR
            elif prior_case in casedetail.case.get_all_future_cases():
                rel_type = CasePrior.RelationType.FUTURE
            else:
                rel_type = CasePrior.RelationType.PRIOR

            return _render_prior_card_response(
                prior_case,
                added=added,
                relation=relation,
                visibility=visibility,
                relation_type=rel_type,
            )
        elif "prior_case_id" in request.POST:
            prior_case = Case.objects.filter(
                pk=request.POST.get("prior_case_id")
            ).first()
            if prior_case is None:
                return HttpResponse("Prior case not found", status=404)

            if prior_case in casedetail.case.get_all_prior_cases():
                rel_type = CasePrior.RelationType.PRIOR
            elif prior_case in casedetail.case.get_all_future_cases():
                rel_type = CasePrior.RelationType.FUTURE
            else:
                rel_type = CasePrior.RelationType.PRIOR

            visibility = request.POST.get("prior_visibility", "AL")
            if visibility not in {
                CasePrior.PriorVisibility.ALWAYS,
                CasePrior.PriorVisibility.REVIEW,
                CasePrior.PriorVisibility.NONE,
            }:
                visibility = CasePrior.PriorVisibility.ALWAYS

            relation = (request.POST.get("relation") or "").strip()
            use_study_date_relation = request.POST.get("use_study_date_relation") == "1"

            if use_study_date_relation:
                current_date = casedetail.case.study_date
                prior_date = prior_case.study_date
                if current_date and prior_date:
                    relative = _format_relative_date_offset(
                        (prior_date - current_date).days
                    )
                    relation = (
                        f"Study date {relative['text']} relative to the index case"
                    )
                else:
                    return _render_prior_card_response(
                        prior_case,
                        added=False,
                        relation=relation,
                        visibility=visibility,
                        relation_type=rel_type,
                        error="Study-date relation requires both this case and the prior case to have study dates.",
                    )

            if not relation:
                # Return the card partial with an inline error so HTMX will replace
                # the card and display the validation message rather than navigating
                # to a new page or showing a plain text response.
                return _render_prior_card_response(
                    prior_case,
                    added=False,
                    relation=relation,
                    visibility=visibility,
                    relation_type=rel_type,
                    error="Enter relation text, or use study-date relation.",
                )

            p, created = CasePrior.objects.get_or_create(
                casedetail=casedetail, prior_case=prior_case
            )
            p.relation_text = relation
            p.prior_visibility = visibility
            p.relation_type = rel_type
            p.save()

            added = True
            relation = p.relation_text
            visibility = p.prior_visibility

            return _render_prior_card_response(
                prior_case,
                added=added,
                relation=relation,
                visibility=visibility,
                relation_type=rel_type,
            )
        else:
            return HttpResponse("False", status=400)

    collection = casedetail.collection

    added_priors = CasePrior.objects.filter(casedetail=casedetail)

    added_relation_map = {}
    for prior in added_priors:
        added_relation_map[prior.prior_case] = (
            prior.relation_text,
            prior.prior_visibility,
            prior.relation_type,
        )

    case = casedetail.case

    available_prior_cases = case.get_all_prior_cases()

    available_priors = []
    for c in available_prior_cases:
        if c in added_relation_map:
            available_priors.append(
                (
                    c,
                    True,
                    added_relation_map[c][0],
                    added_relation_map[c][1],
                    added_relation_map[c][2],
                )
            )
        else:
            available_priors.append((c, False, "", "AL", CasePrior.RelationType.PRIOR))

    available_future_cases = case.get_all_future_cases()

    available_futures = []
    for c in available_future_cases:
        if c in added_relation_map:
            available_futures.append(
                (
                    c,
                    True,
                    added_relation_map[c][0],
                    added_relation_map[c][1],
                    added_relation_map[c][2],
                )
            )
        else:
            available_futures.append(
                (c, False, "", "AL", CasePrior.RelationType.FUTURE)
            )

    form = PriorCaseForm()

    case_number, case_count = collection.get_index_of_case(
        casedetail.case, case_count=True
    )

    previous = collection.get_previous_case(casedetail.case)
    next = collection.get_next_case(casedetail.case)
    # Determine whether the current user can edit this collection/case so
    # included fragments which rely on `can_edit` have the value available.
    can_edit = (
        request.user.is_superuser
        or collection.author.filter(pk=request.user.pk).exists()
    )

    return render(
        request,
        "atlas/collection_case_priors.html",
        {
            "casedetail": casedetail,
            "form": form,
            # "model_form": model_form,
            # "example_form": example_form,
            "collection": collection,
            "case": casedetail.case,
            "previous": previous,
            "next": next,
            "collection_length": case_count,
            "case_number": case_number,
            "added_priors": added_priors,
            "available_priors": available_priors,
            "available_futures": available_futures,
            "can_edit": can_edit,
            # Provide a default `error` value so `{% if error %}` checks in included
            # partials won't raise during variable resolution when the key is absent.
            "error": None,
        },
    )


@user_is_collection_author_or_atlas_editor
def collection_case_questions(request, exam_id, case_number):
    collection = get_object_or_404(CaseCollection, pk=exam_id)
    try:
        case_obj = collection.get_case_by_index(case_number)
        casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
    except Exception:
        raise Http404("Case not found in collection")

    if not collection.collection_type == "QUE":
        raise Http404("Collection not in question mode")

    if request.method == "POST":
        # Called if the user saves the correct answer form
        if request.POST.get("submit") == "answer":
            # answers = request.POST
            casedetail.question_answers = json.loads(request.POST.get("json_answer"))
            casedetail.save()
            example_form = JsonAnswerForm(
                request.POST, question_schema=casedetail.question_schema
            )
            form = CaseQuestionForm(instance=casedetail)
        # Called if the user saves the main form
        elif request.POST.get("submit") == "save":
            form = CaseQuestionForm(request.POST, instance=casedetail)
            if form.is_valid():
                form.save()
                # Add any additional logic or redirection here
            # example_form = JsonAnswerForm(request.POST, question_schema=casedetail.question_schema)
            post_data = request.POST.copy()
            if (
                casedetail.question_schema is not None
                and casedetail.question_answers is not None
            ):
                question_keys = set(casedetail.question_schema["properties"].keys())
                answer_keys = set(casedetail.question_answers.keys())

                answers = {}
                for key, value in casedetail.question_answers.items():
                    if key in question_keys:
                        answers[key] = value

                diff = question_keys - answer_keys

                for key in diff:
                    answers[key] = ""

                post_data["json_answer"] = json.dumps(answers)
                casedetail.question_answers = answers
                casedetail.save()
            example_form = JsonAnswerForm(
                post_data, question_schema=casedetail.question_schema
            )

            form = CaseQuestionForm(instance=casedetail)
        # This shouldn't happen
        else:
            assert False
            form = CaseQuestionForm(request.POST, instance=casedetail)

    else:
        post_data = request.POST.copy()
        post_data["json_answer"] = json.dumps(casedetail.question_answers)
        example_form = JsonAnswerForm(
            post_data, question_schema=casedetail.question_schema
        )

        form = CaseQuestionForm(instance=casedetail)
        pass
        # post_data = None

        # if casedetail.question_schema is not None and casedetail.question_answers is not None:
        #    question_keys = set(casedetail.question_schema["properties"].keys())
        #    answer_keys = set(casedetail.question_answers.keys())

        #    answers = {}
        #    for key, value in casedetail.question_answers.items():
        #        if key in question_keys:
        #            answers[key] = value

        #    diff = question_keys - answer_keys

        #    for key in diff:
        #        answers[key] = ""

        #    post_data = request.POST.copy()
        #    post_data["json_answer"]= json.dumps(answers)
        #    casedetail.question_answers =  answers
        # example_form = JsonAnswerForm(post_data, question_schema=casedetail.question_schema)

        # form = CaseQuestionForm(instance=casedetail)

    # blank_form = JsonAnswerForm(question_schema=casedetail.question_schema)
    case_number, case_count = collection.get_index_of_case(
        casedetail.case, case_count=True
    )

    previous = collection.get_previous_case(casedetail.case)
    next = collection.get_next_case(casedetail.case)

    return render(
        request,
        "atlas/collection_case_questions.html",
        {
            "casedetail": casedetail,
            "form": form,
            "example_form": example_form,
            "collection": collection,
            "case": casedetail.case,
            "previous": previous,
            "next": next,
            "collection_length": case_count,
            "case_number": case_number,
        },
    )


@user_is_collection_author_or_atlas_editor
def collection_case_details(request, exam_id, case_number):
    # Accept either case_number (index into collection) or case_id (case PK).
    collection = get_object_or_404(CaseCollection, pk=exam_id)
    try:
        case_obj = collection.get_case_by_index(int(case_number))
        casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
    except Exception:
        raise Http404("Case not found in collection")

    ordered_series = list(casedetail.case.get_ordered_series())
    visibility_map = casedetail.get_series_visibility_map()
    visibility_choices = [
        (CaseDetail.SeriesVisibility.ALWAYS, "Show all the time"),
        (CaseDetail.SeriesVisibility.REVIEW, "Show on review"),
        (CaseDetail.SeriesVisibility.HIDDEN, "Do not show"),
    ]

    if request.method == "POST":
        form = CaseDetailForm(request.POST, instance=casedetail)
        if form.is_valid():
            casedetail = form.save(commit=False)

            series_visibility_config = {}
            for series in ordered_series:
                field_name = f"series_visibility_{series.pk}"
                visibility_value = request.POST.get(
                    field_name, CaseDetail.SeriesVisibility.ALWAYS
                )
                if visibility_value not in {
                    CaseDetail.SeriesVisibility.ALWAYS,
                    CaseDetail.SeriesVisibility.REVIEW,
                    CaseDetail.SeriesVisibility.HIDDEN,
                }:
                    visibility_value = CaseDetail.SeriesVisibility.ALWAYS
                series_visibility_config[str(series.pk)] = visibility_value

            casedetail.series_visibility_config = series_visibility_config
            casedetail.save()
            if request.htmx:
                return HttpResponse("Saved")
            return redirect(request.path)
        else:
            if request.htmx:
                return HttpResponse("Invalid form", status=400)
    else:
        form = CaseDetailForm(instance=casedetail)

    series_visibility_rows = []
    for series in ordered_series:
        series_visibility_rows.append(
            {
                "series": series,
                "value": visibility_map.get(
                    str(series.pk), CaseDetail.SeriesVisibility.ALWAYS
                ),
            }
        )

    case_number, case_count = collection.get_index_of_case(
        casedetail.case, case_count=True
    )

    previous = collection.get_previous_case(casedetail.case)
    next = collection.get_next_case(casedetail.case)

    return render(
        request,
        "atlas/collection_case_details.html",
        {
            "casedetail": casedetail,
            "form": form,
            "visibility_choices": visibility_choices,
            "series_visibility_rows": series_visibility_rows,
            "collection": collection,
            "case": casedetail.case,
            "previous": previous,
            "next": next,
            "collection_length": case_count,
            "case_number": case_number,
        },
    )


@user_is_collection_author_or_atlas_editor
def collection_mark_overview(request, pk):
    collection = get_object_or_404(CaseCollection, pk=pk)

    if collection.collection_type == "REV":
        raise Http404("Collection in review mode")

    cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()

    casedetails = CaseDetail.objects.filter(
        case__in=cases, collection=collection
    ).prefetch_related("case")

    answers = CidReportAnswer.objects.filter(question__in=casedetails)

    case_map = {}
    for cd in casedetails:
        case_map[cd] = []

    for answer in answers:
        case_map[answer.question].append(answer)

    return render(
        request,
        "atlas/collection_mark_overview.html",
        {
            "collection": collection,
            "answers": answers,
            "casedetails": casedetails,
            "case_map": case_map,
        },
    )


@user_is_collection_author_or_atlas_editor
def collection_mark_question(request, pk, case_number):
    collection = get_object_or_404(CaseCollection, pk=pk)

    if collection.collection_type == "REV":
        raise Http404("Collection in review mode")

    case, case_count = collection.get_case_by_index(case_number, case_count=True)

    previous = case_number > 0
    next = case_number < (case_count - 1)

    casedetail_qs = CaseDetail.objects.filter(case=case, collection=collection)
    casedetail = casedetail_qs.first()

    answers = CidReportAnswer.objects.filter(question__in=casedetail_qs)

    answer_forms = []
    if request.method == "POST":
        for answer in answers:
            if answer:
                form = CidReportAnswerMarkForm(
                    request.POST, instance=answer, prefix=answer.id
                )
            else:
                form = CidReportAnswerMarkForm(request.POST, prefix=answer.id)
            if form.is_valid():
                answer = form.save(commit=False)
                # answer.cid = cid
                # answer.question = casedetail
                # answer.published_date = timezone.now()
                answer.save()

        kwargs = {"pk": pk}

        if "save" in request.POST:
            return redirect(
                "atlas:collection_mark_question", case_number=case_number, **kwargs
            )
        if "next" in request.POST:
            return redirect(
                "atlas:collection_mark_question", case_number=case_number + 1, **kwargs
            )
        elif "previous" in request.POST:
            return redirect(
                "atlas:collection_mark_question", case_number=case_number - 1, **kwargs
            )
        elif "goto" in request.POST:
            return redirect(
                "atlas:collection_mark_question",
                case_number=int(request.POST.get("goto")),
                **kwargs,
            )
    else:
        for answer in answers:
            form = CidReportAnswerMarkForm(instance=answer, prefix=answer.pk)
            answer_forms.append((answer, form))

    series_list = (
        casedetail.get_visible_case_series(review_mode=True) if casedetail else []
    )

    return render(
        request,
        "atlas/collection_mark_question.html",
        {
            "collection": collection,
            "series_list": series_list,
            "answers": answers,
            "casedetail": casedetail,
            "case": case,
            "case_number": case_number,
            "answer_forms": answer_forms,
            "previous": previous,
            "next": next,
        },
    )


@user_is_collection_author_or_atlas_editor
def exam_cids(request, pk):
    collection = get_object_or_404(CaseCollection, pk=pk)

    if (
        request.user in collection.author.all()
        or request.user.groups.filter(name="cid_user_manager").exists()
    ):
        cid_users = collection.valid_cid_users.all()

        cid_user_count = cid_users.count()
        return render(
            request,
            "atlas/exam_cids.html",
            {
                "collection": collection,
                "cid_users": cid_users,
                "cid_user_count": cid_user_count,
            },
        )

    raise PermissionDenied


def collection_take_overview_user(request, pk):
    return collection_take_overview(request, pk)


@login_required
def collection_feedback_overview_user(request, pk: int):
    return collection_feedback_overview(request, pk)


def collection_feedback_overview(
    request, pk: int, cid: int | None = None, passcode: str | None = None
):
    """Show all case conversations for one learner attempt with outstanding items first."""
    collection = get_object_or_404(CaseCollection, pk=pk)
    _enforce_collection_case_query_messaging_enabled(collection)

    try:
        collection.check_user_can_take(cid, passcode, request.user)
    except PrerequisiteRequired as e:
        prereq = getattr(e, "prereq", None)
        return render(
            request,
            "atlas/prerequisite_required.html",
            {"message": str(e), "prereq": prereq, "cid": cid, "passcode": passcode},
        )

    cid_user_exam = collection.get_or_create_cid_user_exam(
        cid=cid, user_user=request.user
    )
    casedetails = list(
        CaseDetail.objects.filter(collection=collection)
        .select_related("case")
        .order_by("sort_order")
    )
    feedback_rows, outstanding_case_count, total_outstanding_feedback = (
        _build_collection_feedback_rows(
            collection=collection,
            cid_user_exam=cid_user_exam,
            casedetails=casedetails,
            cid=cid,
        )
    )

    outstanding_rows = [
        row for row in feedback_rows if row["stats"].get("has_outstanding_feedback")
    ]
    history_rows = [
        row
        for row in feedback_rows
        if row["stats"].get("has_messages")
        and not row["stats"].get("has_outstanding_feedback")
    ]

    return render(
        request,
        "atlas/collection_feedback_overview.html",
        {
            "collection": collection,
            "cid": cid,
            "passcode": passcode,
            "cid_user_exam": cid_user_exam,
            "feedback_rows": feedback_rows,
            "outstanding_rows": outstanding_rows,
            "history_rows": history_rows,
            "outstanding_case_count": outstanding_case_count,
            "total_outstanding_feedback": total_outstanding_feedback,
        },
    )


def collection_take_overview(
    request, pk: int, cid: int | None = None, passcode: str | None = None
):
    """
    Summary page that shows which questions a user has attempted
    (with / without answers)

    Args:
        request (_type_): _description_
        pk (int): _description_
        cid (int | None, optional): _description_. Defaults to None.
        passcode (str | None, optional): _description_. Defaults to None.

    Returns:
        _type_: _description_
    """
    collection = get_object_or_404(CaseCollection, pk=pk)

    try:
        collection.check_user_can_take(cid, passcode, request.user)
    except PrerequisiteRequired as e:
        prereq = getattr(e, "prereq", None)
        return render(
            request,
            "atlas/prerequisite_required.html",
            {"message": str(e), "prereq": prereq, "cid": cid, "passcode": passcode},
        )

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

    if request.htmx:
        if "finish" in request.POST:
            cid_user_exam.completed = True
            cid_user_exam.save()

            collection.exam_user_status.create(
                cid_user_exam=cid_user_exam,
                status="submitted",
                extra="manual submission",
            )

            return HttpResponse("True")
        else:
            raise Http404()

    cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()

    casedetails = list(
        CaseDetail.objects.filter(case__in=cases, collection=collection).select_related(
            "case"
        )
    )

    if cid is not None:
        answers = CidReportAnswer.objects.filter(cid=cid, question__in=casedetails)
    else:
        answers = UserReportAnswer.objects.filter(
            user=request.user, question__in=casedetails
        )

    answer_question_map = {}
    for ans in answers:
        answer_question_map[ans.question] = ans

    case_review_stats, outstanding_case_count, total_outstanding_feedback = (
        _get_case_review_stats_for_exam(
            cid_user_exam=cid_user_exam,
            casedetails=casedetails,
            messaging_enabled=_collection_case_query_messaging_enabled(collection),
        )
    )

    question_answer_tuples = []
    answer_count = 0
    for cd in casedetails:
        self_review = cid_user_exam.selfreview_set.filter(case=cd.case)
        review_stats = case_review_stats.get(cd.case_id, {})
        answer_obj = answer_question_map.get(cd)
        timing_state = _build_case_timing_state(collection, cd, answer_obj)
        if cd in answer_question_map and (
            answer_question_map[cd].answer or answer_question_map[cd].json_answer
        ):
            question_answer_tuples.append(
                (cd, answer_question_map[cd], self_review, review_stats, timing_state)
            )
            answer_count += 1
        else:
            question_answer_tuples.append(
                (cd, None, self_review, review_stats, timing_state)
            )

    post_survey_url = None
    if collection.post_survey:
        user_param = request.user if request.user.is_authenticated else None
        cid_param = int(cid) if cid and str(cid).isdigit() else None

        from django.contrib.contenttypes.models import ContentType
        from survey.models import SurveyResponse

        ct = ContentType.objects.get_for_model(collection)

        has_responded = SurveyResponse.objects.filter(
            survey=collection.post_survey,
            user=user_param,
            cid=cid_param,
            content_type=ct,
            object_id=collection.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": collection.post_survey.pk}
            )
            is_mandatory = getattr(collection, "post_survey_mandatory", False)
            if is_mandatory:
                params = f"?pre_or_post=POST&content_type_id={ct.id}&object_id={collection.pk}&next={request.get_full_path()}&mandatory=true"
                if cid and passcode:
                    params += f"&cid={cid}&passcode={passcode}"
                return redirect(f"{survey_url}{params}")
            else:
                session_key = f"skipped_post_survey_{collection.pk}"
                if not request.session.get(session_key):
                    params = f"?pre_or_post=POST&content_type_id={ct.id}&object_id={collection.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,
        "atlas/collection_take_overview.html",
        {
            "collection": collection,
            "cid": cid,
            "question_answer_tuples": question_answer_tuples,
            "answer_count": answer_count,
            "collection_length": len(cases),
            "passcode": passcode,
            "cid_user_exam": cid_user_exam,
            "outstanding_case_count": outstanding_case_count,
            "total_outstanding_feedback": total_outstanding_feedback,
            "end_overview_resources": collection.end_overview_resources.all(),
            "post_survey_url": post_survey_url,
        },
    )


def _build_case_timing_state(collection, casedetail, answer):
    """Return computed timing state for a collection case attempt.

    - `effective_time_limit`: seconds until case view is locked (None means disabled)
    - `effective_answer_grace_period`: extra answer-only seconds after view lock
    - `case_view_locked`: user can no longer view case content/viewer
    - `answer_entry_closed`: user can no longer submit/edit answer
    """
    effective_time_limit = collection.get_effective_case_time_limit(casedetail)
    effective_answer_grace_period = collection.get_effective_answer_entry_grace_period(
        casedetail
    )

    if effective_time_limit is None:
        return {
            "effective_time_limit": None,
            "effective_answer_grace_period": 0,
            "case_view_locked": False,
            "answer_entry_closed": False,
            "elapsed_seconds": 0,
            "case_view_seconds_remaining": None,
            "answer_entry_seconds_remaining": None,
        }

    started_at = getattr(answer, "started_at", None)
    elapsed_seconds = 0
    if started_at is not None:
        elapsed_seconds = max(0, int((timezone.now() - started_at).total_seconds()))

    case_view_locked = elapsed_seconds >= effective_time_limit
    answer_entry_closed = elapsed_seconds >= (
        effective_time_limit + (effective_answer_grace_period or 0)
    )
    case_view_seconds_remaining = max(0, effective_time_limit - elapsed_seconds)
    answer_entry_seconds_remaining = max(
        0,
        (effective_time_limit + (effective_answer_grace_period or 0)) - elapsed_seconds,
    )

    return {
        "effective_time_limit": effective_time_limit,
        "effective_answer_grace_period": effective_answer_grace_period or 0,
        "case_view_locked": case_view_locked,
        "answer_entry_closed": answer_entry_closed,
        "elapsed_seconds": elapsed_seconds,
        "case_view_seconds_remaining": case_view_seconds_remaining,
        "answer_entry_seconds_remaining": answer_entry_seconds_remaining,
    }


def _collection_sharing_summary(collection, user):
    """Return a dict describing who a registered user's results will be shared with
    for the given collection, before an attempt is created.

    Keys:
        always_visible_to  – list of User objects (authors + markers)
        supervisor         – User | None (linked supervisor account, if any)
        supervisor_always  – bool (results_supervisor_visible on the collection)
        supervisor_opt_in  – bool (user can choose to share with supervisor)
    """
    always_visible = []
    seen_pks = set()
    for attr in ("author", "markers"):
        if hasattr(collection, attr):
            for u in getattr(collection, attr).select_related():
                if u.pk not in seen_pks:
                    seen_pks.add(u.pk)
                    always_visible.append(u)

    supervisor_always = getattr(collection, "results_supervisor_visible", False)
    supervisor_user = None
    supervisor_opt_in = False
    if user and user.is_authenticated:
        try:
            sup = user.userprofile.supervisor
            if sup:
                supervisor_opt_in = True
                if sup.user_id:
                    supervisor_user = sup.user
        except Exception:
            pass

    return {
        "always_visible_to": always_visible,
        "supervisor": supervisor_user,
        "supervisor_always": supervisor_always,
        "supervisor_opt_in": supervisor_opt_in,
    }


@login_required
def collection_exam_sharing_panel(request, pk):
    """HTMX view to display and manage sharing settings for a CidUserExam attempt.

    Scope: attempt owner only (user_user). Read-only rendering for all others.

    GET  – returns the sharing panel partial.
    POST – handles one of three actions (action= toggle_supervisor | add_user | remove_user).
    """
    cid_user_exam = get_object_or_404(CidUserExam, pk=pk)

    if not request.htmx:
        raise PermissionDenied()

    is_owner = bool(
        cid_user_exam.user_user_id and cid_user_exam.user_user_id == request.user.pk
    )

    if request.method == "POST":
        if not is_owner:
            raise PermissionDenied()

        action = request.POST.get("action")

        if action == "toggle_supervisor":
            cid_user_exam.share_with_supervisor = (
                not cid_user_exam.share_with_supervisor
            )
            cid_user_exam.save(update_fields=["share_with_supervisor"])

        elif action == "add_user":
            try:
                user_to_add = User.objects.get(pk=int(request.POST.get("user_id", "")))
                if user_to_add.pk != request.user.pk:
                    cid_user_exam.shared_with_users.add(user_to_add)
            except (User.DoesNotExist, ValueError, TypeError):
                pass

        elif action == "remove_user":
            try:
                user_to_remove = User.objects.get(
                    pk=int(request.POST.get("user_id", ""))
                )
                cid_user_exam.shared_with_users.remove(user_to_remove)
            except (User.DoesNotExist, ValueError, TypeError):
                pass

    sharing_info = cid_user_exam.get_sharing_info()
    return render(
        request,
        "atlas/partials/_sharing_panel.html",
        {
            "cid_user_exam": cid_user_exam,
            "sharing_info": sharing_info,
            "is_owner": is_owner,
        },
    )


@login_required
def collection_exam_user_search(request, pk):
    """HTMX endpoint to search for users to add to a CidUserExam's shared_with_users.

    Scope: attempt owner only.
    GET param q – minimum 2 characters. Returns a small result-list partial.
    """
    cid_user_exam = get_object_or_404(CidUserExam, pk=pk)

    if not request.htmx:
        raise PermissionDenied()

    if not (
        cid_user_exam.user_user_id and cid_user_exam.user_user_id == request.user.pk
    ):
        raise PermissionDenied()

    q = (request.GET.get("q") or "").strip()
    users = []
    if len(q) >= 2:
        already_shared = set(
            cid_user_exam.shared_with_users.values_list("pk", flat=True)
        )
        users = list(
            User.objects.filter(
                Q(first_name__icontains=q)
                | Q(last_name__icontains=q)
                | Q(email__icontains=q)
                | Q(username__icontains=q)
            )
            .exclude(pk=request.user.pk)
            .exclude(pk__in=already_shared)
            .order_by("last_name", "first_name")[:10]
        )

    return render(
        request,
        "atlas/partials/_sharing_user_search_results.html",
        {
            "users": users,
            "cid_user_exam": cid_user_exam,
            "q": q,
        },
    )


@login_required
def collection_shared_with_me(request):
    """Legacy redirect — the shared-with-me page is now part of the supervision page."""
    return redirect(reverse("atlas:collection_supervision"))


@login_required
def collection_shared_attempt_overview(request, cid_user_exam_pk):
    """Read-only overview of a learner's attempt, visible to authorised third parties.

    Accessible to authors, markers, supervisors (when sharing is enabled), and
    users manually added to CidUserExam.shared_with_users.
    """
    cid_user_exam = get_object_or_404(
        CidUserExam.objects.select_related("user_user", "cid_user"),
        pk=cid_user_exam_pk,
    )

    if not cid_user_exam.can_user_view(request.user):
        raise PermissionDenied()

    collection = cid_user_exam.exam
    if not isinstance(collection, CaseCollection):
        raise Http404()

    casedetails = list(
        CaseDetail.objects.filter(collection=collection)
        .select_related("case")
        .order_by("sort_order")
    )

    reviews = cid_user_exam.selfreview_set.filter(
        case__in=[cd.case for cd in casedetails]
    ).order_by("-review_update_date")
    reviews_by_case = {}
    for review in reviews:
        reviews_by_case.setdefault(review.case_id, []).append(review)

    case_review_stats, _, _ = _get_case_review_stats_for_exam(
        cid_user_exam=cid_user_exam,
        casedetails=casedetails,
        messaging_enabled=_collection_case_query_messaging_enabled(collection),
    )

    history_rows = []
    answer_count = 0
    for casedetail in casedetails:
        if cid_user_exam.user_user_id:
            user_answer = casedetail.get_user_answers(cid_user_exam.user_user)
        else:
            user_answer = casedetail.get_cid_answers(cid_user_exam.cid_user.cid)

        if user_answer and (user_answer.answer or user_answer.json_answer):
            answer_count += 1

        history_rows.append(
            {
                "casedetail": casedetail,
                "user_answer": user_answer,
                "self_reviews": reviews_by_case.get(casedetail.case_id, []),
                "case_review_stats": case_review_stats.get(casedetail.case_id, {}),
            }
        )

    return render(
        request,
        "atlas/collection_history_user.html",
        {
            "collection": collection,
            "user": cid_user_exam.get_user_name(),
            "cid_user_exam": cid_user_exam,
            "history_rows": history_rows,
            "answer_count": answer_count,
            "collection_length": len(history_rows),
            "cid": cid_user_exam.cid_user.cid if cid_user_exam.cid_user_id else None,
            "read_only_shared_view": True,
        },
    )


@login_required
def collection_supervision(request):
    """Supervision page: lists all collection attempts visible to the current user as
    an author, marker, supervisor, or manual-share recipient.

    Supports:
      - GET param ``group_by``: ``user`` (default) or ``collection``
      - GET params ``date_from`` / ``date_to``: ISO date strings to filter by
        attempt start_time (inclusive).
      - GET param ``q``: free-text search over learner name / username / email.
      - GET param ``supervisor_only``: ``1`` to restrict to attempts where the current
        user is the assigned supervisor (only shown when the user is a supervisor).
    """
    from django.db.models import Q
    from django.contrib.contenttypes.models import ContentType
    from django.utils.dateparse import parse_date

    collection_ct = ContentType.objects.get_for_model(CaseCollection)

    # ---- determine supervisor status ----
    is_supervisor = False
    trainee_users = User.objects.none()
    trainee_cid_users = CidUser.objects.none()
    try:
        supervisor = request.user.supervisor
        is_supervisor = True
        trainee_users = User.objects.filter(userprofile__supervisor=supervisor)
        trainee_cid_users = CidUser.objects.filter(supervisor=supervisor)
    except Exception:
        pass

    supervisor_only = request.GET.get("supervisor_only", "") == "1" and is_supervisor

    # ---- build base querysets of accessible attempts ----
    manual_qs = CidUserExam.objects.filter(
        shared_with_users=request.user,
        content_type=collection_ct,
    ).exclude(user_user=request.user)

    managed_collection_ids = list(
        CaseCollection.objects.filter(
            Q(author=request.user) | Q(markers=request.user)
        ).values_list("pk", flat=True)
    )
    authored_qs = CidUserExam.objects.filter(
        content_type=collection_ct,
        object_id__in=managed_collection_ids,
    ).exclude(user_user=request.user)

    supervisor_qs = CidUserExam.objects.none()
    supervisor_exam_qs = CidUserExam.objects.none()
    if is_supervisor:
        supervisor_qs = (
            CidUserExam.objects.filter(
                content_type=collection_ct,
                share_with_supervisor=True,
            )
            .filter(Q(user_user__in=trainee_users) | Q(cid_user__in=trainee_cid_users))
            .exclude(user_user=request.user)
        )

        visible_collection_ids = list(
            CaseCollection.objects.filter(results_supervisor_visible=True).values_list(
                "pk", flat=True
            )
        )
        supervisor_exam_qs = (
            CidUserExam.objects.filter(
                content_type=collection_ct,
                object_id__in=visible_collection_ids,
            )
            .filter(Q(user_user__in=trainee_users) | Q(cid_user__in=trainee_cid_users))
            .exclude(user_user=request.user)
        )

    if supervisor_only:
        all_attempts = (supervisor_qs | supervisor_exam_qs).distinct()
    else:
        all_attempts = (
            manual_qs | authored_qs | supervisor_qs | supervisor_exam_qs
        ).distinct()

    all_attempts = all_attempts.select_related("user_user", "cid_user")

    # ---- date filter ----
    date_from_raw = request.GET.get("date_from", "").strip()
    date_to_raw = request.GET.get("date_to", "").strip()
    try:
        if date_from_raw:
            date_from = parse_date(date_from_raw)
            if date_from:
                all_attempts = all_attempts.filter(start_time__date__gte=date_from)
    except Exception:
        pass
    try:
        if date_to_raw:
            date_to = parse_date(date_to_raw)
            if date_to:
                all_attempts = all_attempts.filter(start_time__date__lte=date_to)
    except Exception:
        pass

    # ---- user / learner search ----
    q = request.GET.get("q", "").strip()
    if q:
        all_attempts = all_attempts.filter(
            Q(user_user__first_name__icontains=q)
            | Q(user_user__last_name__icontains=q)
            | Q(user_user__username__icontains=q)
            | Q(user_user__email__icontains=q)
            | Q(cid_user__name__icontains=q)
            | Q(cid_user__email__icontains=q)
        )

    all_attempts = all_attempts.order_by("-start_time")

    # ---- grouping ----
    group_by = request.GET.get("group_by", "user")
    if group_by not in ("user", "collection"):
        group_by = "user"

    grouped = {}
    for attempt in all_attempts:
        collection_obj = attempt.exam
        if group_by == "collection":
            key_label = str(collection_obj) if collection_obj else "Unknown collection"
            key_id = getattr(collection_obj, "pk", 0)
            key = (key_id, key_label)
        else:
            if attempt.user_user_id:
                key_label = (
                    attempt.user_user.get_full_name() or attempt.user_user.username
                )
                key_id = attempt.user_user_id
            elif attempt.cid_user_id:
                name = attempt.cid_user.name or ""
                key_label = f"CID {attempt.cid_user.cid}" + (
                    f" – {name}" if name else ""
                )
                key_id = f"cid_{attempt.cid_user_id}"
            else:
                key_label = "Unknown learner"
                key_id = 0
            key = (key_id, key_label)
        grouped.setdefault(key, []).append((attempt, collection_obj))

    groups = sorted(grouped.items(), key=lambda x: str(x[0][1]).lower())
    groups = [(label, rows) for (_, label), rows in groups]

    return render(
        request,
        "atlas/supervision.html",
        {
            "groups": groups,
            "group_by": group_by,
            "date_from": date_from_raw,
            "date_to": date_to_raw,
            "q": q,
            "is_supervisor": is_supervisor,
            "supervisor_only": supervisor_only,
            "total_attempts": all_attempts.count(),
        },
    )


def collection_case_view_take_user_answers(request, pk: int, case_number: int):
    return collection_case_view_take_answers(request, pk, case_number)


@login_required
@user_is_collection_author_or_atlas_editor
def collection_case_view_take_review_user(
    request, pk: int, case_number: int, user_pk: int
):
    """Read-only case review screen for supervisors and collection authors/editors.

    Renders the same case-taking template as the candidate view but in a purely
    read-only capacity — loading the *target user's* saved answers and exam
    state without mutating anything (no start_time, no answer creation, no POST
    handling).

    Access is gated by ``@user_is_collection_author_or_atlas_editor`` which also
    permits an authorised supervisor when the trainee has enabled
    ``share_with_supervisor`` or the collection is marked
    ``results_supervisor_visible``.

    Args:
        pk: CaseCollection primary key.
        case_number: Zero-based index of the case within the collection.
        user_pk: Primary key of the trainee whose answers are to be viewed.
    """
    from django.contrib.auth import get_user_model

    User = get_user_model()

    collection = get_object_or_404(CaseCollection, pk=pk)
    target_user = get_object_or_404(User, pk=user_pk)

    case, case_count = collection.get_case_by_index(case_number, case_count=True)
    casedetail = CaseDetail.objects.get(case=case, collection=collection)

    # Load the target user's exam record (read-only — no create side effects if
    # using get_or_create, but we deliberately avoid creating one here).
    from generic.models import CidUserExam
    from django.contrib.contenttypes.models import ContentType

    ct = ContentType.objects.get_for_model(CaseCollection)
    cid_user_exam = CidUserExam.objects.filter(
        content_type=ct,
        object_id=collection.pk,
        user_user=target_user,
    ).first()

    # Load existing answer for this case — never create a placeholder.
    answer = None
    form = None
    if collection.collection_type in ("REP", "QUE"):
        answer = casedetail.userreportanswer_set.filter(user=target_user).first()
        if answer:
            if collection.collection_type == "REP":
                from atlas.forms import UserReportAnswerForm

                form = UserReportAnswerForm(instance=answer, casedetail=casedetail)
            else:
                from atlas.forms import UserQuestionAnswerForm

                form = UserQuestionAnswerForm(instance=answer, casedetail=casedetail)

    # Determine visibility / review state — treat the exam as completed for a
    # reviewer so all post-completion sections (report, findings, discussion)
    # are shown, matching what the trainee sees when reviewing their own answers.
    question_completed = True

    show_title = collection.show_title_post
    show_history = collection.show_history_post
    show_description = collection.show_description_post
    show_discussion = collection.show_discussion_post
    show_report = collection.show_report_post

    series_list = casedetail.get_visible_case_series(review_mode=True)

    prior_cases = casedetail.caseprior_set.all()
    series_to_load = []
    for series in series_list:
        series_to_load.append((series, False, ""))
    for prior in prior_cases:
        match prior.prior_visibility:
            case "AL":
                for series in prior.prior_case.get_series():
                    series_to_load.append((series, True, prior.relation_text))
            case "RE":
                for series in prior.prior_case.get_series():
                    series_to_load.append((series, True, prior.relation_text))
            case _:
                continue

    has_priors = any(prior_flag for (_, prior_flag, _) in series_to_load)
    prior_count = prior_cases.count()

    case_review_stats = {}
    self_review = []
    resources = case.caseresource_set.all()

    if cid_user_exam:
        case_review_stats_map, _, _ = _get_case_review_stats_for_exam(
            cid_user_exam=cid_user_exam,
            casedetails=[casedetail],
            messaging_enabled=_collection_case_query_messaging_enabled(collection),
        )
        case_review_stats = case_review_stats_map.get(casedetail.case_id, {})
        self_review = cid_user_exam.selfreview_set.filter(case=case)

    previous = case_number > 0
    next_case = case_number < (case_count - 1)

    return render(
        request,
        "atlas/collection_case_view_take.html",
        {
            "form": form,
            "collection": collection,
            "case": case,
            "casedetail": casedetail,
            "series_list": series_list,
            "named_stacks_json": casedetail.get_case_named_stacks(review_mode=True),
            "series_to_load": series_to_load,
            "has_priors": has_priors,
            "prior_count": prior_count,
            "case_number": case_number,
            "previous": previous,
            "next": next_case,
            "cid": None,
            "passcode": None,
            "answer": answer,
            "show_title": show_title,
            "show_history": show_history,
            "show_description": show_description,
            "show_discussion": show_discussion,
            "show_report": show_report,
            "resources": resources,
            "cid_user_exam": cid_user_exam,
            "case_review_stats": case_review_stats,
            "question_completed": question_completed,
            "self_review": self_review,
            "answer_started_at_iso": answer.started_at.isoformat()
            if answer and getattr(answer, "started_at", None)
            else None,
            "effective_question_time_limit": None,
            "effective_answer_entry_grace_period": None,
            "case_view_locked": False,
            "answer_entry_closed": False,
            "start_screen_resources": collection.start_screen_resources.all(),
            # Read-only mode flags
            "viewing_as_user": True,
            "target_user": target_user,
        },
    )


def collection_case_view_take_answers(
    request, pk: int, case_number: int, cid=None, passcode=None
):
    """ """
    collection = get_object_or_404(CaseCollection, pk=pk)

    try:
        collection.check_user_can_take(cid, passcode, request.user)
    except PrerequisiteRequired as e:
        prereq = getattr(e, "prereq", None)
        return render(
            request,
            "atlas/prerequisite_required.html",
            {"message": str(e), "prereq": prereq, "cid": cid, "passcode": passcode},
        )

    case, case_count = collection.get_case_by_index(case_number, case_count=True)

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

    casedetail = CaseDetail.objects.get(case=case, collection=collection)

    return render(
        request,
        "atlas/collection_case_view_take_answers.html",
        {
            "collection": collection,
            "case": case,
            "casedetail": casedetail,
        },
    )


def collection_case_view_take_user(request, pk, case_number=None, case_id=None):
    # Accept either case_number (index) or case_id (case PK) and forward both to
    # the handler. This allows URLs that use either name to keep working.
    return collection_case_view_take(
        request, pk, case_number=case_number, cid=None, passcode=None, case_id=case_id
    )


def collection_case_view_take(
    request, pk, case_number=None, cid=None, passcode=None, case_id=None
):
    """Candidate-facing collection case taking/review screen.

    This view is for end users taking a case (or reviewing their answers after
    completion). It should remain answer-focused and must not expose management
    edit controls for case content.

    Supports either:
    - ``case_number``: zero-based index within the collection
    - ``case_id``: case PK (translated to the collection index)

    Raises:
        Http404: If access is invalid or no case is specified.
    """
    collection = get_object_or_404(CaseCollection, pk=pk)

    # Check if pre-survey is required and not yet taken
    from survey.views import check_survey_interception

    response = check_survey_interception(request, collection, cid, passcode)
    if response:
        return response

    # If caller provided a case_id (case PK) instead of a case_number (index),
    # translate it into the index used by collection.get_case_by_index().
    if case_id is not None and case_number is None:
        try:
            case_obj = get_case_for_case_detail(case_id)
        except Http404:
            raise
        try:
            case_number = collection.get_index_of_case(case_obj)
        except ValueError:
            raise Http404(f"Case {case_id} not in collection {pk}")
    # Ensure we have a case_number to work with
    if case_number is None:
        raise Http404("No case specified")

    form = None
    answer: None | CidReportAnswer | UserReportAnswer = None

    try:
        collection.check_user_can_take(cid, passcode, request.user)
    except PrerequisiteRequired as e:
        prereq = getattr(e, "prereq", None)
        return render(
            request,
            "atlas/prerequisite_required.html",
            {"message": str(e), "prereq": prereq, "cid": cid, "passcode": passcode},
        )

    case, case_count = collection.get_case_by_index(case_number, case_count=True)

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

    if cid_user_exam.start_time is None:
        cid_user_exam.start_time = timezone.now()
        cid_user_exam.save()

    casedetail = CaseDetail.objects.get(case=case, collection=collection)
    if not collection.review_only():
        if collection.collection_type == "REP":
            if cid is not None:
                answer = casedetail.cidreportanswer_set.filter(cid=cid).first()
                ReportAnswerForm = CidReportAnswerForm
            else:
                answer = casedetail.userreportanswer_set.filter(
                    user=request.user
                ).first()
                ReportAnswerForm = UserReportAnswerForm
        elif collection.collection_type == "QUE":
            if cid is not None:
                answer = casedetail.cidreportanswer_set.filter(cid=cid).first()
                ReportAnswerForm = CidQuestionAnswerForm
            else:
                answer = casedetail.userreportanswer_set.filter(
                    user=request.user
                ).first()
                ReportAnswerForm = UserQuestionAnswerForm

        # If we have an answer object but it doesn't have a started_at, set it now.
        if answer and getattr(answer, "started_at", None) is None:
            try:
                answer.started_at = timezone.now()
                answer.save()
            except Exception:
                # Non-fatal: if saving fails, continue without blocking the user
                logger.exception("Failed to set started_at on answer", exc_info=True)

        # If no answer exists yet, create a placeholder so we have a started_at timestamp
        if not answer and collection.collection_type in ("REP", "QUE"):
            try:
                if cid is not None:
                    answer = CidReportAnswer(question=casedetail, cid=cid)
                else:
                    answer = UserReportAnswer(question=casedetail, user=request.user)
                answer.started_at = timezone.now()
                answer.save()
            except Exception:
                # If creation fails, leave answer as None; the normal flow will handle form creation
                answer = None
                logger.exception(
                    "Failed to create initial answer object", exc_info=True
                )

    timing_state = _build_case_timing_state(collection, casedetail, answer)

    if (
        request.method != "POST"
        and timing_state["answer_entry_closed"]
        and answer
        and not answer.completed
    ):
        answer.completed = True
        if not answer.submitted_at:
            answer.submitted_at = timezone.now()
        answer.save()

    if request.method == "POST":
        if collection.collection_type in ("REP", "QUE"):
            if not collection.publish_results:
                if answer:
                    form = ReportAnswerForm(
                        request.POST, instance=answer, casedetail=casedetail
                    )
                else:
                    form = ReportAnswerForm(request.POST, casedetail=casedetail)

                timed_out_submission = request.POST.get("timed_out") == "1"

                if not cid_user_exam.completed and not (
                    answer is not None and answer.completed
                ):
                    if timing_state["answer_entry_closed"] and not timed_out_submission:
                        if answer is not None and not answer.completed:
                            answer.completed = True
                            if not answer.submitted_at:
                                answer.submitted_at = timezone.now()
                            answer.save()

                        is_ajax = (
                            request.headers.get("HX-Request", "").lower() == "true"
                            or request.headers.get("x-requested-with")
                            == "XMLHttpRequest"
                            or getattr(request, "htmx", False)
                        )
                        if is_ajax:
                            return JsonResponse(
                                {
                                    "status": "closed",
                                    "locked": True,
                                    "submitted_at": answer.submitted_at.isoformat()
                                    if answer and getattr(answer, "submitted_at", None)
                                    else None,
                                }
                            )

                    if form.is_valid():
                        answer = form.save(commit=False)
                        answer.set_cid_or_user(cid=cid, user=request.user)
                        answer.question = casedetail
                        # Record submission timestamp
                        answer.submitted_at = timezone.now()
                        # answer.published_date = timezone.now()

                        if "complete_case" in request.POST:
                            if not collection.self_review:
                                raise Http404("Self review not enabled")
                            answer.completed = True

                        # If this was a timed-out submission, mark as completed
                        if request.POST.get("timed_out") == "1":
                            answer.completed = True
                            ## Also mark the overall exam as completed for this user/CID
                            # try:
                            #    cid_user_exam.completed = True
                            #    cid_user_exam.save()
                            #    # Record an exam status entry
                            #    collection.exam_user_status.create(
                            #        cid_user_exam=cid_user_exam,
                            #        status="submitted",
                            #        extra="autosubmit",
                            #    )
                            # except Exception:
                            #    logger.exception("Failed to mark cid_user_exam completed on autosubmit")

                        answer.save()

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

                        # If this was an AJAX/HTMX request, return JSON so the client
                        # can remain on the same page and update the UI without redirect.
                        # Detect HTMX/XHR requests robustly. HTMX sets the HX-Request header.
                        is_ajax = (
                            request.headers.get("HX-Request", "").lower() == "true"
                            or request.headers.get("x-requested-with")
                            == "XMLHttpRequest"
                            or getattr(request, "htmx", False)
                        )
                        if is_ajax:
                            return JsonResponse(
                                {
                                    "status": "ok",
                                    "locked": answer.completed,
                                    "submitted_at": answer.submitted_at.isoformat()
                                    if getattr(answer, "submitted_at", None)
                                    else None,
                                }
                            )

        if cid is not None:
            kwargs = {"pk": pk, "cid": cid, "passcode": passcode}
            redirect_url = "atlas:collection_case_view_take"
            overview_url = "atlas:collection_take_overview"
        else:
            kwargs = {"pk": pk}
            redirect_url = "atlas:collection_case_view_take_user"
            overview_url = "atlas:collection_take_overview_user"

        if "next" in request.POST:
            return redirect(redirect_url, case_number=case_number + 1, **kwargs)
        elif "previous" in request.POST:
            return redirect(redirect_url, case_number=case_number - 1, **kwargs)
        elif "finish" in request.POST:
            return redirect(overview_url, **kwargs)
        elif "goto" in request.POST:
            return redirect(
                redirect_url,
                case_number=int(request.POST.get("goto")),
                **kwargs,
            )
    else:
        if collection.collection_type in ("REP", "QUE"):
            form = ReportAnswerForm(instance=answer, casedetail=casedetail)

    previous = case_number > 0
    next = case_number < (case_count - 1)

    # Set what details a user is / is not able to view when taking the case
    # this can be configured via the collection edit menu
    if (
        collection.publish_results
        or cid_user_exam.completed
        or (
            not collection.feedback_once_collection_complete
            and answer is not None
            and answer.completed
        )
    ):
        question_completed = True
        show_title = collection.show_title_post
        show_history = collection.show_history_post
        show_description = collection.show_description_post
        show_discussion = collection.show_discussion_post
        show_report = collection.show_report_post

        self_review = cid_user_exam.selfreview_set.filter(case=case)

        resources = case.caseresource_set.all()
    else:
        question_completed = False
        show_title = collection.show_title_pre
        show_history = collection.show_history_pre
        show_description = collection.show_description_pre
        show_discussion = collection.show_discussion_pre
        show_report = collection.show_report_pre
        self_review = []

        resources = case.caseresource_set.filter(pre_review=True)

    series_list = casedetail.get_visible_case_series(review_mode=question_completed)

    prior_cases = casedetail.caseprior_set.all()

    series_to_load = []

    for series in series_list:
        series_to_load.append((series, False, ""))

    for prior in prior_cases:
        match prior.prior_visibility:
            case "AL":
                for series in prior.prior_case.get_series():
                    series_to_load.append((series, True, prior.relation_text))
            case "RE":
                if question_completed:
                    for series in prior.prior_case.get_series():
                        series_to_load.append((series, True, prior.relation_text))
            case _:
                continue

    # Determine whether any prior series will be shown to the candidate.
    has_priors = any(prior_flag for (_, prior_flag, _) in series_to_load)
    prior_count = prior_cases.count()
    case_review_stats, _, _ = _get_case_review_stats_for_exam(
        cid_user_exam=cid_user_exam,
        casedetails=[casedetail],
        messaging_enabled=_collection_case_query_messaging_enabled(collection),
    )

    case_view_locked = not question_completed and timing_state["case_view_locked"]
    answer_entry_closed = not question_completed and timing_state["answer_entry_closed"]

    return render(
        request,
        "atlas/collection_case_view_take.html",
        {
            "form": form,
            "collection": collection,
            "case": case,
            "casedetail": casedetail,
            "series_list": series_list,
            "named_stacks_json": casedetail.get_case_named_stacks(
                review_mode=question_completed
            ),
            "series_to_load": series_to_load,
            "has_priors": has_priors,
            "prior_count": prior_count,
            "case_number": case_number,
            "previous": previous,
            "next": next,
            "cid": cid,
            "passcode": passcode,
            "answer": answer,
            "show_title": show_title,
            "show_history": show_history,
            "show_description": show_description,
            "show_discussion": show_discussion,
            "show_report": show_report,
            "resources": resources,
            "cid_user_exam": cid_user_exam,
            "case_review_stats": case_review_stats.get(casedetail.case_id, {}),
            "question_completed": question_completed,
            "self_review": self_review,
            "answer_started_at_iso": answer.started_at.isoformat()
            if answer and getattr(answer, "started_at", None)
            else None,
            "effective_question_time_limit": timing_state["effective_time_limit"],
            "effective_answer_entry_grace_period": timing_state[
                "effective_answer_grace_period"
            ],
            "case_view_locked": case_view_locked,
            "answer_entry_closed": answer_entry_closed,
            "start_screen_resources": collection.start_screen_resources.all(),
        },
    )


@user_is_collection_author_or_atlas_editor
def collection_case_view(request, pk, case_number=None, case_id=None):
    """Management view for a case within a collection.

    This view is used by collection authors/atlas editors to manage case
    content in collection context. It renders the full case detail template and
    provides ``can_edit`` for inline editing widgets/partials.

    Supports either:
    - ``case_number``: zero-based index within the collection
    - ``case_id``: case PK (translated to the collection index)

    Raises:
        Http404: If no valid case is specified.
    """
    collection = get_object_or_404(CaseCollection, pk=pk)

    # Support either case_number (index) or case_id (case PK) in the URL/kwargs.
    if case_id is not None and case_number is None:
        try:
            case_obj = get_case_for_case_detail(case_id)
        except Http404:
            raise
        try:
            case_number = collection.get_index_of_case(case_obj)
        except ValueError:
            raise Http404(f"Case {case_id} not in collection {pk}")
    if case_number is None:
        raise Http404("No case specified")

    form = None
    answer = None

    case, case_count = collection.get_case_by_index(case_number, case_count=True)

    case = get_case_for_case_detail(case.pk)

    casedetail = CaseDetail.objects.get(case=case, collection=collection)

    series_list = case.series.all().prefetch_related("images", "examination", "plane")
    ordered_series = list(getattr(case, "ordered_series", case.get_ordered_series()))
    for series in ordered_series:
        prefetched_images = getattr(series, "_prefetched_objects_cache", {}).get(
            "images"
        )
        if prefetched_images is not None:
            series.visible_image_count = len(prefetched_images)
        else:
            series.visible_image_count = series.images.filter(removed=False).count()

    move_series_form = MoveSelectedSeriesForm(user=request.user, source_case=case)
    move_series_recent_cases = list(
        get_cases_available_to_user(request.user)
        .exclude(pk=case.pk)
        .filter(author=request.user)
        .order_by("-created_date")
        .prefetch_related("author", "condition", "presentation", "subspecialty")[:5]
    )

    previous = collection.get_previous_case(casedetail.case)
    next = collection.get_next_case(casedetail.case)
    can_edit = case.check_user_can_edit(request.user)

    return render(
        request,
        "atlas/case_detail.html",
        {
            "form": form,
            "collection": collection,
            "collection_length": case_count,
            "case": case,
            "casedetail": casedetail,
            "series_list": series_list,
            "ordered_series": ordered_series,
            "case_number": case_number,
            "previous": previous,
            "next": next,
            "can_edit": can_edit,
            "move_series_form": move_series_form,
            "move_series_recent_cases": move_series_recent_cases,
            "answer": answer,
            "cimar_sid": request.user.userprofile.cimar_sid,
            "site_queryset": Site.objects.all(),
            "subspecialty_queryset": Subspecialty.objects.all(),
        },
    )


def collection_dicom_json(request, pk):
    collection = get_object_or_404(CaseCollection, pk=pk)

    return JsonResponse(collection.get_ohif_dicom_json())


@user_is_author_or_atlas_editor
def use_dates_as_descriptions(request, pk):
    case = get_object_or_404(Case, pk=pk)

    series: Series
    for series in case.series.all():
        series.use_date_as_description()

    return HttpResponse("Done")


def collection_case_dicom_json_review(request, exam_id, case_number):
    return collection_case_dicom_json(request, exam_id, case_number, review=True)


def collection_case_dicom_json(request, exam_id, case_number, review=False):
    # Support either case_number (index) or case_id (case PK)
    collection = get_object_or_404(CaseCollection, pk=exam_id)
    try:
        case_obj = collection.get_case_by_index(int(case_number))
        casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
    except Exception:
        raise Http404("Case not found in collection")

    main_series = casedetail.get_visible_case_series(review_mode=review)

    studies_json = {
        "studies": [
            {
                "StudyInstanceUID": "1.3.6.1.4.1.14519.5.2.1.6279.6001.298806137288633453246975630178",
                "StudyTime": "",
                "PatientName": casedetail.case.title,
                "PatientID": "LIDC-IDRI-0001",
                "AccessionNumber": "",
                "PatientAge": "",
                "PatientSex": "",
                "series": [series.get_series_dicom_json() for series in main_series],
            }
        ]
    }

    prior_qs = casedetail.caseprior_set.all().select_related("prior_case")
    for prior in prior_qs:
        if prior.prior_visibility == CasePrior.PriorVisibility.NONE:
            continue
        if prior.prior_visibility == CasePrior.PriorVisibility.REVIEW and not review:
            continue
        studies_json["studies"].append(
            {
                "StudyInstanceUID": "1.3.6.1.4.1.14519.5.2.1.6279.6001.298806137288633453246975630177",
                "StudyTime": "",
                "PatientName": casedetail.case.title,
                "PatientID": "LIDC-IDRI-0001",
                "AccessionNumber": "",
                "PatientAge": "",
                "PatientSex": "",
                "series": [
                    series.get_series_dicom_json()
                    for series in prior.prior_case.get_ordered_series()
                ],
                "StudyDescription": f"{'Prior' if prior.relation_type == CasePrior.RelationType.PRIOR else 'Future'}: {prior.relation_text}",
            }
        )

    return JsonResponse(studies_json)


def case_dicom_json(request, pk):
    case = get_object_or_404(Case, pk=pk)

    return JsonResponse(case.get_case_dicom_json())


@login_required
def resource_search_widget(request):
    """HTMX endpoint: search existing `Resource` objects with simple filters.

    GET params:
    - q: query string (name/description)
    - site: site id (optional)
    - subspecialty: subspecialty id (optional)
    - open_access: '1' or '0' (optional)

    Returns rendered partial `atlas/partials/resource_search_widget_results.html`.
    """

    q = request.GET.get("q", "").strip()
    site = request.GET.get("site")
    subspecialty = request.GET.get("subspecialty")
    open_access = request.GET.get("open_access")

    qs = Resource.objects.all()
    if q:
        qs = qs.filter(
            Q(name__icontains=q) | Q(description__icontains=q) | Q(url__icontains=q)
        )
    if site:
        try:
            qs = qs.filter(sites__id=int(site))
        except Exception:
            pass
    if subspecialty:
        try:
            qs = qs.filter(subspecialty__id=int(subspecialty))
        except Exception:
            pass
    if open_access in ["0", "1"]:
        qs = qs.filter(open_access=(open_access == "1"))

    # Restrict to what the user may see: open_access or authored (unless editor)
    if not (
        request.user.is_superuser
        or request.user.groups.filter(name="atlas_editor").exists()
    ):
        qs = qs.filter(Q(open_access=True) | Q(author__id=request.user.id)).distinct()

    results = qs.order_by("name")[:50]

    field = request.GET.get("field", "resource_search")
    case_id = request.GET.get("case_id")
    return render(
        request,
        "atlas/partials/resource_search_widget_results.html",
        {"results": results, "field": field, "case_id": case_id},
    )


@login_required
def resource_search_panel(request):
    """HTMX endpoint: return the full resource search panel HTML fragment.

    Expects optional GET param `case_id` to prefill the hidden field.
    """
    case_id = request.GET.get("case_id")
    return render(
        request,
        "atlas/partials/resource_search_panel.html",
        {
            "case": (Case.objects.filter(pk=case_id).first() if case_id else None),
            "site_queryset": Site.objects.all(),
            "subspecialty_queryset": Subspecialty.objects.all(),
        },
    )


@login_required
@require_POST
def case_attach_resource(request, pk):
    """Attach an existing resource to the case (HTMX POST).

    POST params:
    - resource_id: integer
    - pre_review: '1' or '0' (optional)

    Returns the updated `atlas/partials/case_resources_list.html` fragment.
    """
    case = get_case_for_case_detail(pk)
    if not case.check_user_can_edit(request.user):
        return HttpResponse(status=403)

    resource_id = request.POST.get("resource_id")
    pre_review = request.POST.get("pre_review", "0") == "1"
    try:
        resource = Resource.objects.get(pk=int(resource_id))
    except Exception:
        return HttpResponse(status=400)

    # create if not exists
    cr, created = CaseResource.objects.get_or_create(
        case=case, resource=resource, defaults={"pre_review": pre_review}
    )
    if not created:
        # update flag
        cr.pre_review = pre_review
        cr.save()
    # Refresh case from DB to ensure related sets reflect changes and render updated list
    try:
        case = Case.objects.get(pk=case.pk)
    except Exception:
        pass
    html = render_to_string(
        "atlas/partials/case_resources_list.html", {"case": case}, request=request
    )
    resp = HttpResponse(html)
    # Notify any HTMX listeners that the case resources have changed
    resp["HX-Trigger"] = json.dumps({"caseResourcesChanged": {"case_id": case.pk}})
    return resp


@login_required
@require_POST
def case_detach_resource(request, pk):
    case = get_case_for_case_detail(pk)
    if not case.check_user_can_edit(request.user):
        return HttpResponse(status=403)
    resource_id = request.POST.get("resource_id")
    try:
        cr = CaseResource.objects.filter(case=case, resource__id=int(resource_id))
        cr.delete()
    except Exception:
        pass
    # Refresh case from DB and return updated partial
    try:
        case = Case.objects.get(pk=case.pk)
    except Exception:
        pass
    html = render_to_string(
        "atlas/partials/case_resources_list.html", {"case": case}, request=request
    )
    resp = HttpResponse(html)
    resp["HX-Trigger"] = json.dumps({"caseResourcesChanged": {"case_id": case.pk}})
    return resp


@login_required
@require_POST
def case_toggle_resource_pre(request, pk):
    """Toggle the pre_review flag on a CaseResource (HTMX POST).

    POST params:
    - resource_id: integer

    Returns the updated `atlas/partials/case_resources_list.html` fragment.
    """
    case = get_case_for_case_detail(pk)
    if not case.check_user_can_edit(request.user):
        return HttpResponse(status=403)

    resource_id = request.POST.get("resource_id")
    try:
        cr = CaseResource.objects.get(case=case, resource__id=int(resource_id))
    except Exception:
        return HttpResponse(status=400)

    cr.pre_review = not bool(cr.pre_review)
    cr.save()

    # Refresh case from DB to ensure related sets reflect changes and render updated list
    try:
        case = Case.objects.get(pk=case.pk)
    except Exception:
        pass
    html = render_to_string(
        "atlas/partials/case_resources_list.html", {"case": case}, request=request
    )
    resp = HttpResponse(html)
    resp["HX-Trigger"] = json.dumps({"caseResourcesChanged": {"case_id": case.pk}})
    return resp


@login_required
def case_create_resource_inline(request, pk):
    """Create a new resource and attach it to the case (HTMX).

    GET: Renders the inline form `ResourceForm`.
    POST: Processes the form, saves new Resource, sets author, attaches to case,
          and returns updated `atlas/partials/case_resources_list.html` partial.
    """
    case = get_case_for_case_detail(pk)
    if not case.check_user_can_edit(request.user):
        return HttpResponse(status=403)

    if request.method == "POST":
        form = ResourceForm(request.POST, request.FILES)
        if form.is_valid():
            resource = form.save(commit=False)
            resource.save()
            resource.author.add(request.user.id)

            pre_review = request.POST.get("pre_review") == "on" or request.POST.get("pre_review") == "1"
            CaseResource.objects.create(
                case=case,
                resource=resource,
                pre_review=pre_review,
            )

            # Refresh case from DB to render updated resources list
            try:
                case = Case.objects.get(pk=case.pk)
            except Exception:
                pass
            html = render_to_string(
                "atlas/partials/case_resources_list.html", {"case": case}, request=request
            )
            add_resource_button_html = (
                f'<div id="resource-search-panel-placeholder" hx-swap-oob="true" class="d-inline-flex">'
                f'  <button class="btn btn-sm btn-outline-primary" '
                f'          hx-get="{reverse("atlas:resource_search_panel")}?case_id={case.pk}" '
                f'          hx-target="#resource-search-panel-placeholder" '
                f'          hx-swap="innerHTML"> '
                f'      Add Resource '
                f'  </button>'
                f'</div>'
            )
            resp = HttpResponse(html + add_resource_button_html)
            resp["HX-Trigger"] = json.dumps({"caseResourcesChanged": {"case_id": case.pk}})
            return resp
    else:
        form = ResourceForm()

    return render(
        request,
        "atlas/partials/case_create_resource_inline.html",
        {"form": form, "case": case},
    )


def series_dicom_json(request, pk):
    series = get_object_or_404(Series, pk=pk)

    return JsonResponse(series.get_ohif_dicom_json())


def series_tags_partial(request, pk):
    logger.info(f"Fetching tags for series {pk}")
    # Accept either a numeric PK or a SeriesInstanceUID string
    series = None
    try:
        if str(pk).isdigit():
            series = Series.objects.filter(pk=int(pk)).first()
    except Exception:
        series = None

    if series is None:
        # Try exact match first
        series = Series.objects.filter(series_instance_uid=pk).first()

    if series is None:
        # Try URL-decoded value and stripped variants
        try:
            import urllib.parse

            decoded = urllib.parse.unquote(pk)
        except Exception:
            decoded = pk

        if decoded != pk:
            series = Series.objects.filter(series_instance_uid=decoded).first()

    if series is None:
        # Fallback to contains (be permissive for slightly mangled UIDs)
        try:
            series = Series.objects.filter(series_instance_uid__contains=pk).first()
        except Exception:
            series = None

    images = None
    if series is not None:
        images = list(series.images.all())
    else:
        # If no Series exists yet, try to find uploaded uncategorised dicoms
        try:
            dicoms = UncategorisedDicom.objects.filter(series_instance_uid=pk)
        except Exception:
            dicoms = UncategorisedDicom.objects.none()

        if not dicoms.exists():
            try:
                import urllib.parse

                decoded = urllib.parse.unquote(pk)
            except Exception:
                decoded = pk

            if decoded != pk:
                dicoms = UncategorisedDicom.objects.filter(series_instance_uid=decoded)

        if not dicoms.exists():
            try:
                dicoms = UncategorisedDicom.objects.filter(
                    series_instance_uid__contains=pk
                )
            except Exception:
                dicoms = UncategorisedDicom.objects.none()

        if not dicoms.exists():
            return HttpResponse(status=404)

        images = list(dicoms)
    num_images = len(images)

    tag_values = {}
    tag_counts = {}

    for img in images:
        # Uploaded uncategorised dicoms use a different API than SeriesImage
        try:
            if isinstance(img, UncategorisedDicom):
                try:
                    ds = pydicom.dcmread(img.image)
                except Exception:
                    # skip invalid dicoms
                    continue
            else:
                ds = img.get_dicom_data()
        except Exception:
            # Defensive: skip items that don't expose expected methods
            continue
        if not ds:
            continue
        # Iterate elements
        for elem in ds:
            kw = getattr(elem, "keyword", None) or str(elem.tag)
            try:
                val = elem.value
            except Exception:
                val = None
            if isinstance(val, pydicom.multival.MultiValue):
                sval = ",".join(map(str, val))
            else:
                sval = str(val) if val is not None else ""

            tag_counts.setdefault(kw, 0)
            tag_counts[kw] += 1
            tag_values.setdefault(kw, set()).add(sval)

    common_tags = {}
    for k, vals in tag_values.items():
        if tag_counts.get(k, 0) == num_images and len(vals) == 1:
            common_tags[k] = list(vals)[0]

    html = render_to_string(
        "atlas/partials/series_tags.html", {"common_tags": common_tags}, request=request
    )
    return HttpResponse(html)


@user_is_collection_author_or_atlas_editor
def delete_collection_cid_answers(request, exam_id, cid):
    collection = get_object_or_404(CaseCollection, pk=exam_id)

    cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()

    casedetails = CaseDetail.objects.filter(
        case__in=cases, collection=collection
    ).prefetch_related("case")

    cid_user_answers = CidReportAnswer.objects.filter(question__in=casedetails, cid=cid)

    temp = {}
    for ans in cid_user_answers:
        temp[ans.pk] = (ans.question.pk, ans.score, ans.cid, ans.answer)
        ans.delete()

    return JsonResponse(temp)


@user_is_collection_author_or_atlas_editor
def delete_collection_answers(request, exam_id):
    collection = get_object_or_404(CaseCollection, pk=exam_id)

    cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()

    casedetails = CaseDetail.objects.filter(
        case__in=cases, collection=collection
    ).prefetch_related("case")

    cid_user_answers = CidReportAnswer.objects.filter(question__in=casedetails)

    temp = {}
    for ans in cid_user_answers:
        temp[ans.pk] = (ans.question.pk, ans.score, ans.cid, ans.answer)
        ans.delete()

    return JsonResponse(temp)


# TODO: this needs a major cleanup
@user_is_collection_author_or_atlas_editor
def collection_scores_cid(request, pk):
    collection = get_object_or_404(CaseCollection, pk=pk)

    if not collection.exam_mode:
        raise Http404("Collection not in exam mode")

    user_answers_and_marks = defaultdict(list)
    user_answers_marks = defaultdict(list)
    user_answers = defaultdict(list)
    user_names = {}
    # cid_passcodes = {}

    by_question = defaultdict(dict)
    unmarked = set()

    cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()

    casedetails = CaseDetail.objects.filter(
        case__in=cases, collection=collection
    ).prefetch_related("case")

    cid_user_answers = CidReportAnswer.objects.filter(question__in=casedetails)

    cids = set()

    case_list = list(cases)

    # Loop through all candidates
    for cid_user_answer in cid_user_answers:
        # Convoluted (probably...)
        cid = cid_user_answer.cid
        # cid_passcodes[cid] = cid_user_answer.passcode
        cids.add(cid)
        s = cid_user_answer
        user_names[cid] = cid

        q = cid_user_answer.question.case

        # if not s:
        #    # skip if no answer
        #    user_answers_marks[cid].append(0)
        #    user_answers[cid].append("")
        #    by_question[q].append(("", 0))
        #    continue

        ans = s.answer

        answer_score = s.get_answer_score()
        if answer_score == "unmarked" or answer_score == None:
            index = case_list.index(q)
            unmarked.add(index)
        user_answers[cid].append(ans)
        user_answers_marks[cid].append(answer_score)
        user_answers_and_marks[cid].append((ans, answer_score))

        by_question[q][cid] = (ans, answer_score, s.feedback)

    user_scores = {}
    user_scores_normalised = {}
    for user in user_answers_marks:
        user_scores[user] = sum(
            [i for i in user_answers_marks[user] if (i != "unmarked" and i != None)]
        )

        user_scores_normalised[user] = user_scores[user]

    # ignore scores of 0 for stats
    user_scores_list = [i for i in user_scores.values() if i > 0]

    # Use collection's case_question_max_score if set, otherwise default to 10
    case_max = collection.case_question_max_score or 10
    max_score = len(cases) * case_max

    if len(user_scores_list) < 1:
        mean = 0
        median = 0
        mode = 0
        fig_html = ""
    else:
        mean = statistics.mean(user_scores_list)
        median = statistics.median(user_scores_list)
        try:
            mode = statistics.mode(user_scores_list)
        except statistics.StatisticsError:
            mode = "No unique mode"

        df = user_scores_list
        fig = px.histogram(
            df,
            x=0,
            title="{}: distribution of scores".format(collection),
            labels={"0": "Score"},
            height=400,
            width=600,
        )
        fig_html = fig.to_html()

        collection.stats_mean = mean
        collection.stats_median = median
        collection.stats_mode = mode

        collection.stats_candidates = len(user_scores_list)
        collection.stats_max_possible = max_score

        collection.stats_min = min(user_scores_list)
        collection.stats_max = max(user_scores_list)

        # collection.stats_graph = fig_html

        collection.user_scores = user_scores

        collection.save()

    return render(
        request,
        f"atlas/collection_scores.html",
        {
            "cids": sorted(cids),
            # "cid_passcodes": cid_passcodes,
            "exam": collection,
            "collection": collection,
            "unmarked": unmarked,
            "questions": cases,
            "by_question": by_question,
            "user_answers": dict(user_answers),
            "user_answers_marks": dict(user_answers_marks),
            "user_scores": user_scores,
            "user_scores_normalised": user_scores_normalised,
            "user_scores_list": user_scores_list,
            "user_names": user_names,
            "user_answers_and_marks": user_answers_and_marks,
            "max_score": max_score,
            "mean": mean,
            "median": median,
            "mode": mode,
            "plot": fig_html,
        },
    )


GenericExamViews = ExamViews(
    CaseCollection, Case, None, CidReportAnswer, "atlas", "casecollection"
)


class CollectionCloneMixin:
    def get_initial(self):
        old_object = get_object_or_404(self.model, pk=self.kwargs["exam_id"])
        initial_data = model_to_dict(old_object, exclude=["id"])

        # We manually transfer the forign keys / m2m relationships
        questions = old_object.cases.all().values_list("id", flat=True)
        authors = old_object.author.all().values_list("id", flat=True)

        self.cases = list(questions)
        self.author = list(authors)

        return initial_data

    def form_valid(self, form):
        object = form.save()
        # Reapply these otherwise they get lost?
        object.cases.set(self.cases)
        object.author.set(self.author)
        object.save()
        return HttpResponseRedirect(object.get_absolute_url())


class CollectionClone(CollectionCloneMixin, CaseCollectionCreate):
    """Clone exam view"""


class SelfReviewDelete(DeleteView):
    """Delete a user's self-review entry for a specific case attempt.

    The view resolves ``user_exam``, ``case`` and ``review`` from URL params and,
    on success, returns the learner to the case take page.
    """

    model = SelfReview
    template_name = "confirm_delete.html"
    # success_url = reverse_lazy("atlas:finding_view")

    def get_initial(self):
        user_exam_id = self.kwargs["user_exam_id"]
        case_id = self.kwargs["case_id"]
        review_id = self.kwargs["pk"]

        self.user_exam = get_object_or_404(CidUserExam, pk=user_exam_id)
        self.case = get_object_or_404(Case, pk=case_id)
        self.review = get_object_or_404(SelfReview, pk=review_id)
        return {"user_exam": user_exam_id, "case": case_id, "review": review_id}

    def get_context_data(self, **kwargs):
        # This can also be accessed via view.**** in the template
        context = super(SelfReviewDelete, self).get_context_data(**kwargs)
        context["user_exam"] = self.user_exam
        context["case"] = self.case
        context["review"] = self.review
        return context

    def get_success_url(self) -> str:
        return self.user_exam.exam.get_case_take_url(self.case)


class SelfReviewDetail(DetailView):
    """Read-only self-review view for a specific user exam and case.

    This is the default viewing mode for self reviews. Add ``?embed=1`` to
    render the compact sidebar-friendly template.
    """

    model = SelfReview

    def get_template_names(self):
        if self.request.GET.get("embed") == "1":
            return ["atlas/partials/_selfreview_detail_embed.html"]
        return ["atlas/selfreview_detail.html"]

    def get_object(self, queryset=None):
        return get_object_or_404(
            SelfReview,
            pk=self.kwargs["pk"],
            user_exam_id=self.kwargs["user_exam_id"],
            case_id=self.kwargs["case_id"],
        )

    def get_context_data(self, **kwargs):
        context = super(SelfReviewDetail, self).get_context_data(**kwargs)
        context["review"] = self.object
        context["user_exam"] = self.object.user_exam
        context["case"] = self.object.case
        context["embed"] = self.request.GET.get("embed") == "1"
        return context


class SelfReviewUpdate(UpdateView):
    """Edit an existing self-review entry for a case attempt.

    Uses ``SelfReviewForm`` and redirects back to the case take page after save.
    """

    model = SelfReview
    form_class = SelfReviewForm

    def get_template_names(self):
        if self.request.GET.get("embed") == "1":
            return ["atlas/partials/_selfreview_form_embed.html"]
        return ["atlas/selfreview_form.html"]

    def get_initial(self):
        user_exam_id = self.kwargs["user_exam_id"]
        case_id = self.kwargs["case_id"]
        review_id = self.kwargs["pk"]

        self.user_exam = get_object_or_404(CidUserExam, pk=user_exam_id)
        self.case = get_object_or_404(Case, pk=case_id)
        self.review = get_object_or_404(SelfReview, pk=review_id)
        return {"user_exam": user_exam_id, "case": case_id, "review": review_id}

    def get_context_data(self, **kwargs):
        # This can also be accessed via view.**** in the template
        context = super(SelfReviewUpdate, self).get_context_data(**kwargs)
        context["user_exam"] = self.user_exam
        context["case"] = self.case
        context["review"] = self.review
        context["embed"] = self.request.GET.get("embed") == "1"
        context["popup"] = self.request.GET.get("popup") == "1"
        return context

    def get_success_url(self) -> str:
        if getattr(self, "object", None) is not None:
            return self.object.user_exam.exam.get_case_take_url(self.object.case)

        user_exam_id = self.kwargs["user_exam_id"]
        case_id = self.kwargs["case_id"]
        user_exam = get_object_or_404(CidUserExam, pk=user_exam_id)
        case = get_object_or_404(Case, pk=case_id)
        return user_exam.exam.get_case_take_url(case)

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

        if self.request.GET.get("popup") == "1":
            return render(
                self.request,
                "atlas/partials/_selfreview_popup_success.html",
                {"target_url": self.get_success_url()},
            )

        return HttpResponseRedirect(self.get_success_url())

    # def get_object(self):
    #    return self.review
    #    return
    # def get_initial(self):
    #    user_exam_id = self.kwargs["user_exam_id"]
    #    case_id = self.kwargs["case_id"]

    #    self.user_exam = get_object_or_404(CidUserExam, pk=user_exam_id)
    #    self.case = get_object_or_404(Case, pk=case_id)
    #    return {"user_exam": user_exam_id, "case": case_id}


class AddSelfReview(CreateView):
    """Create a new self-review entry linked to a user exam and case.

    The form captures free-text comments and 1-5 self-assessment scores for
    findings and interpretation, then returns the learner to the case take page.
    """

    model = SelfReview
    form_class = SelfReviewForm

    def get_template_names(self):
        if self.request.GET.get("embed") == "1":
            return ["atlas/partials/_selfreview_form_embed.html"]
        return ["atlas/selfreview_form.html"]

    # fields = ("author", "note_type", "note")

    def get_form_kwargs(self):
        kwargs = super(AddSelfReview, self).get_form_kwargs()
        # update the kwargs for the form init method with yours
        kwargs.update(self.kwargs)  # self.kwargs contains all url conf params
        # kwargs.update({"user": self.request.user})
        return kwargs

    def get_initial(self):
        user_exam_id = self.kwargs["user_exam_id"]
        case_id = self.kwargs["case_id"]

        self.user_exam = get_object_or_404(CidUserExam, pk=user_exam_id)
        self.case = get_object_or_404(Case, pk=case_id)
        return {"user_exam": user_exam_id, "case": case_id}

    def get_context_data(self, **kwargs):
        # This can also be accessed via view.**** in the template
        context = super(AddSelfReview, self).get_context_data(**kwargs)
        context["user_exam"] = self.user_exam
        context["case"] = self.case
        context["embed"] = self.request.GET.get("embed") == "1"
        context["popup"] = self.request.GET.get("popup") == "1"
        return context

    def get_success_url(self) -> str:
        if getattr(self, "object", None) is not None:
            return self.object.user_exam.exam.get_case_take_url(self.object.case)

        user_exam_id = self.kwargs["user_exam_id"]
        case_id = self.kwargs["case_id"]
        user_exam = get_object_or_404(CidUserExam, pk=user_exam_id)
        case = get_object_or_404(Case, pk=case_id)
        return user_exam.exam.get_case_take_url(case)

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

        if self.request.GET.get("popup") == "1":
            return render(
                self.request,
                "atlas/partials/_selfreview_popup_success.html",
                {"target_url": self.get_success_url()},
            )

        return HttpResponseRedirect(self.get_success_url())
        # return reverse("atlas:collection_take_overview_user", kwargs={"pk": self.user_exam.collection.id })


#    def form_valid(self, form):
#        model = form.save(commit=False)
#
#        model.save()
#
#        response = super().form_valid(form)
#
#        return response


@login_required
def uncategorised_dicoms(request):
    dicoms = UncategorisedDicom.objects.filter(user=request.user)

    data = []
    for d in dicoms:
        data.append((d.image.name, d.get_basic_dicom_tags()))

    return JsonResponse(data, safe=False)


@login_required
def combine_series(request):
    """Combines a list of series into the first series in the list

    NOTE: this will only migrate images
    it will not migrate other models such as SeriesFinding or other details
    """
    series_ids = request.POST.getlist("series-ids")
    # series = get_object_or_404(Series, pk=series_id)
    if len(series_ids) < 2:
        return HttpResponse("Not enough series to combine")

    base_series = None
    series_to_add = []

    for n, series_id in enumerate(series_ids):
        series = get_object_or_404(Series, pk=series_id)
        if not series.check_user_can_edit(request.user):
            return HttpResponse("You do not have permission to edit one of the series")

        if n == 0:
            base_series = series
        else:
            series_to_add.append(series)

    for series in series_to_add:
        for image in series.images.all():
            image.series = base_series
            image.save()

        series.delete()

    return HttpResponse(f"Series {series_ids} combines")
    # return HttpResponse("Fail")


class CaseCollectionAuthorUpdate(RevisionMixin, AuthorRequiredMixin, UpdateView):
    model = CaseCollection
    form_class = CaseCollectionAuthorForm
    template_name = "author_form.html"

    def get_context_data(self, **kwargs):
        context = super(CaseCollectionAuthorUpdate, self).get_context_data(**kwargs)
        context["collection"] = context["object"]
        return context


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

    def get_context_data(self, **kwargs):
        context = super(ExamGroupsUpdate, self).get_context_data(**kwargs)
        context["exam"] = context["object"]
        return context


class CaseAuthorUpdate(RevisionMixin, AuthorRequiredMixin, UpdateView):
    model = Case
    form_class = CaseAuthorForm
    template_name = "author_form.html"

    def get_context_data(self, **kwargs):
        context = super(CaseAuthorUpdate, self).get_context_data(**kwargs)
        context["case"] = context["object"]
        return context


class SeriesAuthorUpdate(RevisionMixin, AuthorRequiredMixin, UpdateView):
    model = Series
    form_class = SeriesAuthorForm
    template_name = "author_form.html"

    def get_context_data(self, **kwargs):
        context = super(SeriesAuthorUpdate, self).get_context_data(**kwargs)
        context["collection"] = context["object"]
        return context


def question_schemas_preset(request):
    schemas = [
        json.dumps(
            {
                "type": "object",
                "title": "Questions",
                "required": ["laterality", "diagnosis"],
                "properties": {
                    "test": {
                        "type": "string",
                        "title": "test string",
                        "description": "What side is the lesion on?",
                    },
                    "diagnosis": {
                        "enum": [
                            "Meningioma",
                            "Schwannoma",
                            "Neurofibroma",
                            "Ependymoma",
                        ],
                        "type": "string",
                        "title": "Diagnosis",
                        "description": "What is the diagnosis?",
                    },
                    "laterality": {
                        "enum": ["left", "right"],
                        "type": "string",
                        "title": "Laterality",
                        "widget": "radio",
                        "description": "What side is the lesion on?",
                    },
                    "laterality (copy 2)": {
                        "enum": ["left", "right"],
                        "type": "string",
                        "title": "Lateralityaoue",
                        "widget": "radio",
                        "description": "What side is the lesion on?",
                    },
                },
            }
        ),
    ]

    return render(
        request,
        "atlas/question_schemas_preset.html",
        {
            "schemas": schemas,
        },
    )


def collection_question_schemas(request, exam_id: int):
    collection = get_object_or_404(CaseCollection, pk=exam_id)

    questions = collection.cases.all().prefetch_related()

    casedetails = (
        CaseDetail.objects.filter(case__in=questions, collection=collection)
        .prefetch_related("case")
        .order_by("sort_order")
    )

    return render(
        request,
        "atlas/collection_question_schemas.html",
        {
            "collection": collection,
            "casedetails": casedetails,
        },
    )


@user_is_collection_author_or_atlas_editor
def collection_apply_question_template(request, pk: int):
    """Apply the collection's case_question_template to all CaseDetail entries.

    Scope: Collection author / atlas_editor. HTMX endpoint.
    Accepts optional 'overwrite' query param to overwrite existing schemas.
    """
    if not request.htmx:
        raise Http404("Invalid request")

    collection = get_object_or_404(CaseCollection, pk=pk)

    if not collection.case_question_template:
        return HttpResponse(
            '<div class="alert alert-warning">No question template set on this collection.</div>'
        )

    overwrite = request.GET.get("overwrite", "false").lower() == "true"
    updated_count = collection.apply_question_template_to_cases(
        overwrite_existing=overwrite
    )

    return HttpResponse(
        f'<div class="alert alert-success">Applied question template to {updated_count} case(s).</div>'
    )


@user_is_collection_author_or_atlas_editor
def collection_sync_prerequisite_users(request, pk: int):
    """Copy valid cid_users and user_users from all prerequisite collections into this collection.

    This endpoint is intended to be called via HTMX from the collection detail page.
    """
    if not request.htmx:
        raise Http404("Invalid request")

    collection = get_object_or_404(CaseCollection, pk=pk)

    # Collect the closure of collections: the target collection and all prerequisites (recursively)
    to_visit = [collection]
    visited = {}
    while to_visit:
        col = to_visit.pop()
        if col.pk in visited:
            continue
        visited[col.pk] = col
        for p in col.prerequisites.all():
            if p.pk not in visited:
                to_visit.append(p)

    collections = list(visited.values())

    # Compute the union of all cid users and user users across the set
    union_cid_users = set()
    union_user_users = set()
    union_cid_groups = set()
    union_user_groups = set()
    for col in collections:
        union_cid_users.update(list(col.valid_cid_users.all()))
        union_user_users.update(list(col.valid_user_users.all()))
        union_cid_groups.update(list(col.cid_user_groups.all()))
        union_user_groups.update(list(col.user_user_groups.all()))

    # Apply the union to each collection and count additions
    total_added_cid = 0
    total_added_user = 0
    total_added_cid_groups = 0
    total_added_user_groups = 0
    per_collection_added = []
    for col in collections:
        added_cid = 0
        added_user = 0
        added_cid_groups = 0
        added_user_groups = 0
        for cid_user in union_cid_users:
            if not col.valid_cid_users.filter(pk=cid_user.pk).exists():
                col.valid_cid_users.add(cid_user)
                added_cid += 1
        for user in union_user_users:
            if not col.valid_user_users.filter(pk=user.pk).exists():
                col.valid_user_users.add(user)
                added_user += 1
        # Sync groups as well
        for gid in union_cid_groups:
            if not col.cid_user_groups.filter(pk=gid.pk).exists():
                col.cid_user_groups.add(gid)
                added_cid_groups += 1
        for ug in union_user_groups:
            if not col.user_user_groups.filter(pk=ug.pk).exists():
                col.user_user_groups.add(ug)
                added_user_groups += 1
        added_user += added_user_groups
        added_cid += added_cid_groups
        if added_cid or added_user:
            col.save()
        total_added_cid += added_cid
        total_added_user += added_user
        total_added_cid_groups += added_cid_groups
        total_added_user_groups += added_user_groups
        per_collection_added.append(
            (col.pk, added_cid, added_user, added_cid_groups, added_user_groups)
        )

    html = render_to_string(
        "atlas/partials/sync_prereq_result.html",
        {
            "total_added_cid": total_added_cid,
            "total_added_user": total_added_user,
            "total_added_cid_groups": total_added_cid_groups,
            "total_added_user_groups": total_added_user_groups,
            "per_collection_added": per_collection_added,
        },
        request=request,
    )

    return HttpResponse(html)


def collection_reset_answers_user_list(request, exam_id: int):
    if request.htmx:
        collection = get_object_or_404(CaseCollection, pk=exam_id)
        cid_users = collection.cid_users.all()

        if cid_users:
            return render(
                request,
                "atlas/collection_reset_answers_user_list.html",
                {"collection": collection, "cid_users": cid_users},
            )
        else:
            return HttpResponse("No user answers.")

    else:
        raise Http404("Invalid request")


@user_is_collection_author_or_atlas_editor
def collection_reset_answers_user(request, exam_id: int, user_id: int):
    if request.htmx:
        collection = get_object_or_404(CaseCollection, pk=exam_id)

        # Select all case details (answers are linked to these)
        casedetails = collection.casedetail_set.all().prefetch_related()

        # Delete all answers
        for case in casedetails:
            user_answers = case.userreportanswer_set.filter(user_id=user_id)
            user_answers.delete()

        collection.cid_users.filter(user_user_id=user_id).delete()
        collection.exam_user_status.filter(cid_user_exam__user_user_id=user_id).delete()

        return HttpResponse(f"User [{user_id}] answers deleted")

    else:
        raise Http404("Invalid request")


@user_is_collection_author_or_atlas_editor
def collection_reset_answers_ciduser(request, exam_id: int, cid: int):
    if request.htmx:
        collection = get_object_or_404(CaseCollection, pk=exam_id)

        # Select all case details (answers are linked to these)
        casedetails = collection.casedetail_set.all().prefetch_related()

        # Delete all answers
        for case in casedetails:
            cid_answers = case.cidreportanswer_set.filter(cid=cid)
            cid_answers.delete()

        collection.cid_users.filter(cid=cid).delete()
        collection.exam_user_status.filter(cid_user_exam__cid__pk=cid).delete()

        return HttpResponse(f"CID [{cid}] answers deleted")

    else:
        raise Http404("Invalid request")


def collection_reset_answers(request, exam_id: int):
    if request.htmx:
        collection = get_object_or_404(CaseCollection, pk=exam_id)

        # Select all case details (answers are linked to these)
        casedetails = collection.casedetail_set.all().prefetch_related()

        # Delete all answers
        for case in casedetails:
            cid_answers = case.cidreportanswer_set.all()

            cid_answers.delete()
            user_answers = case.userreportanswer_set.all()
            user_answers.delete()

        # User statuses
        collection.exam_user_status.all().delete()

        # CidUserExams
        collection.cid_users.all().delete()
        return HttpResponse("Success")

    else:
        raise Http404("Invalid request")


def push_case_to_cimar(request, case_id):
    push_case_to_cimar_task.enqueue(case_id=case_id)

    return HttpResponse("Push started")
    case = get_object_or_404(Case, pk=case_id)

    api = CimarAPI()
    api.login(username=CIMAR_USERNAME, password=CIMAR_PASSWORD)

    # We use the same study_uid for all images in a case
    study_uid = generate_uid()

    for series in case.get_series():
        for image in series.images.all():
            data = api.upload_dicom(image.image.path, study_uid=study_uid)

    cimar_uuid = api.get_study_by_study_uid(data["study_uid"])["uuid"]

    case.cimar_uuid = cimar_uuid

    case.save()

    cimar_case, created = CimarCase.objects.get_or_create(uuid=cimar_uuid)

    cimar_case.refresh_study()

    return HttpResponse("Success")


@require_POST
def remove_case_from_collection(request, case_pk, collection_pk):
    case = get_object_or_404(Case, pk=case_pk)
    collection = get_object_or_404(CaseCollection, pk=collection_pk)

    # Remove the case from the collection
    collection.cases.remove(case)

    # If this was an HTMX request, return an empty response so the caller
    # can swap out (remove) the surrounding element (e.g., the <li>).
    if request.htmx:
        return HttpResponse("")

    return HttpResponse(f"Case removed from collection: {collection.name}")


@login_required
@require_POST
def remove_selected_series_from_case(request, case_pk):
    ids = request.POST.getlist("series-ids") or request.POST.getlist("series_ids")
    case = get_object_or_404(Case, pk=case_pk)

    if not case.check_user_can_edit(request.user):
        return HttpResponse("You do not have permission to edit this case.", status=403)

    if not ids:
        return HttpResponse("No series selected for removal.", status=400)

    linked_series_ids = list(
        Series.objects.filter(pk__in=ids, case=case)
        .values_list("pk", flat=True)
        .distinct()
    )
    if not linked_series_ids:
        return HttpResponse("No valid series selected for removal.", status=400)

    # Remove the association (for a through model)
    SeriesDetail.objects.filter(case=case, series_id__in=linked_series_ids).delete()
    return HttpResponse(f"Removed {len(linked_series_ids)} series from this case.")


@login_required
@require_POST
def move_selected_series_to_case(request, case_pk):
    source_case = get_object_or_404(Case, pk=case_pk)

    if not source_case.check_user_can_edit(request.user):
        return HttpResponse("You do not have permission to edit this case.", status=403)

    form = MoveSelectedSeriesForm(
        request.POST, user=request.user, source_case=source_case
    )
    if not form.is_valid():
        return HttpResponse("Please choose a destination case.", status=400)

    target_case = form.cleaned_data["destination_case"]
    if not target_case.check_user_can_edit(request.user):
        return HttpResponse(
            "You do not have permission to edit the destination case.", status=403
        )

    ids = request.POST.getlist("series-ids")
    if not ids:
        return HttpResponse("No series selected to move.")

    series_to_move = list(
        Series.objects.filter(pk__in=ids, case=source_case).distinct()
    )
    if not series_to_move:
        return HttpResponse("No valid series selected to move.", status=400)

    for series in series_to_move:
        if not series.check_user_can_edit(request.user):
            return HttpResponse(
                "You do not have permission to edit one or more selected series.",
                status=403,
            )

    next_sort_order = (
        SeriesDetail.objects.filter(case=target_case).aggregate(
            max_sort=Max("sort_order")
        )["max_sort"]
        or -1
    ) + 1

    added_to_target = 0
    for series in series_to_move:
        link_exists = SeriesDetail.objects.filter(
            case=target_case, series=series
        ).exists()
        if not link_exists:
            SeriesDetail.objects.create(
                case=target_case,
                series=series,
                sort_order=next_sort_order,
            )
            next_sort_order += 1
            added_to_target += 1

        SeriesDetail.objects.filter(case=source_case, series=series).delete()

    return HttpResponse(
        f"Moved {len(series_to_move)} series to case {target_case.pk}. Added {added_to_target} new links in destination case."
    )


@require_POST
def reorder_series(request, case_pk):
    case = get_object_or_404(Case, pk=case_pk)
    if not case.check_user_can_edit(request.user):
        return HttpResponse("You do not have permission to edit this case.", status=403)
    # Get new order from POST
    order = []
    for key in sorted([k for k in request.POST if k.startswith("series_order_")]):
        order.append(int(request.POST[key]))
    # Update sort_order in SeriesDetail
    for idx, series_id in enumerate(order):
        SeriesDetail.objects.filter(case=case, series_id=series_id).update(
            sort_order=idx
        )
    return redirect(request.META.get("HTTP_REFERER", "/"))


def linked_cases_overview(request, case_id):
    """
    Display and manage a linked-case chain.

    Scope:
    - Read-only overview of previous/current/next linked cases.
    - Study-date editing for relative timeline management.
    - On-demand bulk case-series editor for updating full case fields across the chain.
    """
    case = get_object_or_404(Case, pk=case_id)

    previous_cases = []
    try:
        current_case = case.previous_case
        while current_case:
            previous_cases.insert(0, current_case)
            current_case = current_case.previous_case
    except ObjectDoesNotExist:
        pass

    next_cases = []
    try:
        current_case = case.next_case
        while current_case:
            next_cases.append(current_case)
            current_case = current_case.next_case
    except ObjectDoesNotExist:
        pass

    chain_cases = previous_cases + [case] + next_cases
    chain_case_ids = [linked_case.pk for linked_case in chain_cases]
    editable_chain_case_ids = [
        linked_case.pk
        for linked_case in chain_cases
        if linked_case.check_user_can_edit(request.user)
    ]
    direct_previous = previous_cases[-1] if previous_cases else None
    direct_next = next_cases[0] if next_cases else None

    case_editor_prefix = "case_series_editor"
    show_study_dates_editor = request.GET.get("show_dates") == "1"
    show_case_editor = request.GET.get("show_editor") == "1"
    show_reorder_editor = request.GET.get("show_reorder") == "1"
    case_editor_selected_field = ""
    case_editor_selected_source = ""

    case_editor_apply_fields = [
        field_name
        for field_name in CaseForm.Meta.fields
        if field_name != "previous_case"
    ]
    case_editor_apply_field_choices = [
        (
            field_name,
            str(Case._meta.get_field(field_name).verbose_name)
            .replace("_", " ")
            .title(),
        )
        for field_name in case_editor_apply_fields
    ]

    case_editor_formset_factory = modelformset_factory(Case, form=CaseForm, extra=0)

    def build_case_editor_formset(data=None):
        queryset = Case.objects.filter(pk__in=editable_chain_case_ids).prefetch_related(
            "subspecialty",
            "condition",
            "presentation",
            "pathological_process",
            "procedures",
        )
        formset = case_editor_formset_factory(
            data=data,
            queryset=queryset,
            prefix=case_editor_prefix,
            form_kwargs={"user": request.user},
        )
        forms_by_pk = {form.instance.pk: form for form in formset.forms}
        ordered_rows = []
        for linked_case in chain_cases:
            form = forms_by_pk.get(linked_case.pk)
            if form is None:
                continue
            ordered_rows.append(
                {
                    "case": linked_case,
                    "form": form,
                    "is_current": linked_case.pk == case.pk,
                }
            )
        return formset, ordered_rows

    case_editor_formset = None
    case_editor_rows = []

    if request.method == "POST":
        form_action = request.POST.get("form_action", "study_dates")

        if form_action == "case_series_editor":
            if not editable_chain_case_ids:
                raise PermissionDenied(
                    "You do not have permission to edit this case chain."
                )

            show_case_editor = True
            case_editor_selected_field = request.POST.get("apply_field", "")
            case_editor_selected_source = request.POST.get("apply_from_case", "")
            case_editor_formset, case_editor_rows = build_case_editor_formset(
                request.POST
            )

            if case_editor_formset.is_valid():
                case_editor_formset.save()

                applied_count = 0
                if case_editor_selected_field and case_editor_selected_source:
                    if case_editor_selected_field not in case_editor_apply_fields:
                        messages.error(
                            request,
                            "Select a valid field to apply across the linked series.",
                        )
                    else:
                        source_case = Case.objects.filter(
                            pk=case_editor_selected_source,
                            pk__in=editable_chain_case_ids,
                        ).first()
                        if source_case is None:
                            messages.error(
                                request,
                                "Select a valid source case for field propagation.",
                            )
                        else:
                            target_cases = Case.objects.filter(
                                pk__in=editable_chain_case_ids
                            ).exclude(pk=source_case.pk)
                            case_field = Case._meta.get_field(
                                case_editor_selected_field
                            )
                            if case_field.many_to_many:
                                source_values = list(
                                    getattr(
                                        source_case, case_editor_selected_field
                                    ).all()
                                )
                                for target_case in target_cases:
                                    getattr(
                                        target_case, case_editor_selected_field
                                    ).set(source_values)
                                    applied_count += 1
                            else:
                                source_value = getattr(
                                    source_case, case_editor_selected_field
                                )
                                for target_case in target_cases:
                                    if (
                                        getattr(target_case, case_editor_selected_field)
                                        != source_value
                                    ):
                                        setattr(
                                            target_case,
                                            case_editor_selected_field,
                                            source_value,
                                        )
                                        target_case.save(
                                            update_fields=[case_editor_selected_field]
                                        )
                                        applied_count += 1

                            if applied_count:
                                messages.success(
                                    request,
                                    f"Applied {case_editor_selected_field} to {applied_count} linked case(s).",
                                )

                messages.success(request, "Saved linked case editor changes.")
                return redirect(
                    f"{reverse('atlas:linked_cases_overview', kwargs={'case_id': case.pk})}?show_editor=1"
                )

            messages.error(
                request, "Please correct the errors in the linked case editor form."
            )

        elif form_action == "reorder_cases":
            if not case.check_user_can_edit(request.user):
                raise PermissionDenied(
                    "You do not have permission to edit this case chain."
                )

            # Get new order from POST
            order = []
            for key in sorted([k for k in request.POST if k.startswith("case_order_")]):
                try:
                    order.append(int(request.POST[key]))
                except ValueError:
                    pass

            if set(order) == set(chain_case_ids):
                from django.db import transaction

                with transaction.atomic():
                    cases = list(Case.objects.filter(pk__in=chain_case_ids))
                    for c in cases:
                        c.previous_case = None
                        c.save(update_fields=["previous_case"])

                    cases_by_id = {c.pk: c for c in cases}
                    for i, pk in enumerate(order):
                        if i > 0:
                            current_c = cases_by_id[pk]
                            prev_c = cases_by_id[order[i - 1]]
                            current_c.previous_case = prev_c
                            current_c.save(update_fields=["previous_case"])

                messages.success(request, "Saved case chain order.")
                return redirect(
                    f"{reverse('atlas:linked_cases_overview', kwargs={'case_id': case.pk})}?show_reorder=1"
                )
            else:
                messages.error(request, "Invalid case ordering submitted.")
                return redirect(
                    f"{reverse('atlas:linked_cases_overview', kwargs={'case_id': case.pk})}?show_reorder=1"
                )

        else:
            if not case.check_user_can_edit(request.user):
                raise PermissionDenied(
                    "You do not have permission to edit this case chain."
                )

            show_study_dates_editor = True

            apply_to_all_from = request.POST.get("apply_to_all_from")
            updated_cases = 0

            if apply_to_all_from:
                source_case = next(
                    (
                        linked_case
                        for linked_case in chain_cases
                        if str(linked_case.pk) == apply_to_all_from
                    ),
                    None,
                )
                if source_case and source_case.check_user_can_edit(request.user):
                    source_date = _parse_study_date(
                        request.POST.get(f"study_date_{source_case.pk}")
                    )
                    for linked_case in chain_cases:
                        if not linked_case.check_user_can_edit(request.user):
                            continue
                        if linked_case.study_date != source_date:
                            linked_case.study_date = source_date
                            linked_case.save(update_fields=["study_date"])
                            updated_cases += 1
                    messages.success(
                        request, f"Applied the study date to {updated_cases} case(s)."
                    )
            else:
                for linked_case in chain_cases:
                    if not linked_case.check_user_can_edit(request.user):
                        continue
                    raw_value = request.POST.get(
                        f"study_date_{linked_case.pk}", ""
                    ).strip()
                    new_date = _parse_study_date(raw_value)
                    if linked_case.study_date != new_date:
                        linked_case.study_date = new_date
                        linked_case.save(update_fields=["study_date"])
                        updated_cases += 1
                messages.success(
                    request, f"Updated {updated_cases} linked case date(s)."
                )

            return redirect(
                f"{reverse('atlas:linked_cases_overview', kwargs={'case_id': case.pk})}?show_dates=1"
            )

    if show_case_editor and editable_chain_case_ids:
        case_editor_formset, case_editor_rows = build_case_editor_formset()

    linked_case_rows = []
    case_relative_labels = {}
    anchor_date = next(
        (
            linked_case.get_effective_study_date()
            for linked_case in chain_cases
            if linked_case.get_effective_study_date()
        ),
        None,
    )
    for linked_case in chain_cases:
        study_date = linked_case.get_effective_study_date()
        relative_days = None
        relative_label = None
        if anchor_date and study_date:
            relative_days = (study_date - anchor_date).days
            relative_label = _format_relative_date_offset(relative_days)

        case_relative_labels[linked_case.pk] = relative_label
        linked_case.relative_label = relative_label
        linked_case_rows.append(
            {
                "case": linked_case,
                "study_date": study_date,
                "relative_days": relative_days,
                "relative_label": relative_label,
                "can_edit": linked_case.check_user_can_edit(request.user),
                "is_current": linked_case.pk == case.pk,
            }
        )

    return render(
        request,
        "atlas/linked_cases_overview.html",
        {
            "current_case": case,
            "previous_cases": previous_cases,
            "next_cases": next_cases,
            "chain_cases": chain_cases,
            "linked_case_rows": linked_case_rows,
            "anchor_date": anchor_date,
            "can_edit_chain": any(row["can_edit"] for row in linked_case_rows),
            "chain_total": len(chain_cases),
            "current_index": len(previous_cases) + 1,
            "direct_previous": direct_previous,
            "direct_next": direct_next,
            "show_study_dates_editor": show_study_dates_editor,
            "show_case_editor": show_case_editor,
            "show_reorder_editor": show_reorder_editor,
            "case_editor_formset": case_editor_formset,
            "case_editor_rows": case_editor_rows,
            "case_editor_apply_field_choices": case_editor_apply_field_choices,
            "case_editor_selected_field": case_editor_selected_field,
            "case_editor_selected_source": case_editor_selected_source,
            "case_editor_prefix": case_editor_prefix,
            "editable_case_count": len(editable_chain_case_ids),
            "locked_case_count": len(chain_case_ids) - len(editable_chain_case_ids),
            "case_relative_labels": case_relative_labels,
        },
    )


@login_required
def add_series_to_case(request, series_pk):
    if request.method == "POST":
        case_id = request.POST.get("case_id")
        case = get_object_or_404(Case, pk=case_id)
        if not case.check_user_can_edit(request.user):
            raise PermissionDenied("You do not have permission to edit this case.")

        series = get_object_or_404(Series, pk=series_pk)

        # Associate the series with the case
        series.case.add(case)

        # If this is an HTMX request, return the updated cases list partial so the
        # client can replace the #cases-list innerHTML. Otherwise return a simple message.
        if request.headers.get("Hx-Request") or request.META.get("HTTP_HX_REQUEST"):
            return render(
                request,
                "atlas/partials/series_cases_list.html",
                {"series": series, "can_edit": case.check_user_can_edit(request.user)},
            )

        return HttpResponse(
            f"Series {series_pk} added to case {case.title} ({case_id})."
        )
    return HttpResponse("Invalid request.")


@login_required
def remove_series_from_case(request, series_pk, case_pk):
    if request.method == "POST":
        case = get_object_or_404(Case, pk=case_pk)
        if not case.check_user_can_edit(request.user):
            raise PermissionDenied("You do not have permission to edit this case.")

        series = get_object_or_404(Series, pk=series_pk)

        # Remove the association
        series.case.remove(case)

        # If this is an HTMX request, return the updated cases list partial so the
        # client can replace the #cases-list innerHTML. Otherwise return a simple message.
        if request.headers.get("Hx-Request") or request.META.get("HTTP_HX_REQUEST"):
            return render(
                request,
                "atlas/partials/series_cases_list.html",
                {"series": series, "can_edit": case.check_user_can_edit(request.user)},
            )

        return HttpResponse("Removed")
    return HttpResponse("Invalid request.")


@login_required
def series_finding_details(request, finding_id):
    finding = get_object_or_404(SeriesFinding, pk=finding_id)
    return render(request, "atlas/series_finding_details.html", {"finding": finding})


@login_required
def series_finding_preview(request, finding_id):
    """Return a small HTML fragment summarising a SeriesFinding for popups/modals."""
    finding = get_object_or_404(SeriesFinding, pk=finding_id)
    return render(
        request, "atlas/partials/_seriesfinding_preview.html", {"finding": finding}
    )


@login_required
def series_finding_related(request, series_id):
    series = get_object_or_404(Series, pk=series_id)
    findings = series.get_related_findings()
    return render(
        request,
        "atlas/series_finding_related.html",
        {"series": series, "findings": findings},
    )


def seriesfinding_migration_status(request):
    """
    View to list all SeriesFinding objects and their migration status.
    Shows whether annotation_json_3d and viewer_state_3d are populated.
    """
    findings = SeriesFinding.objects.all().select_related()
    findings_status = []
    for finding in findings:
        findings_status.append(
            {
                "id": finding.id,
                "str": str(finding),
                "annotation_json_3d": bool(finding.annotation_json_3d),
                "viewer_state_3d": bool(finding.viewer_state_3d),
                "series": finding.series,
            }
        )
    return render(
        request,
        "atlas/seriesfinding_migration_status.html",
        {"findings_status": findings_status},
    )


@login_required
@require_POST
def series_bulk_delete(request):
    ids = request.POST.getlist("series_ids") or request.POST.getlist("series-ids")
    if not ids:
        response = HttpResponse("No series selected to delete.", status=400)
        return response if request.htmx else redirect("atlas:series_view")

    series_to_delete = list(Series.objects.filter(pk__in=ids).distinct())
    if not series_to_delete:
        response = HttpResponse("No valid series selected to delete.", status=400)
        return response if request.htmx else redirect("atlas:series_view")

    forbidden_series = [
        series.pk
        for series in series_to_delete
        if not series.check_user_can_edit(request.user)
    ]
    if forbidden_series:
        response = HttpResponse(
            f"You do not have permission to delete {len(forbidden_series)} selected series.",
            status=403,
        )
        return response if request.htmx else redirect("atlas:series_view")

    deleted_count = len(series_to_delete)
    for series in series_to_delete:
        series.delete()

    response = HttpResponse(f"Deleted {deleted_count} series.")
    if request.htmx:
        return response
    return redirect(request.META.get("HTTP_REFERER") or reverse("atlas:series_view"))


@user_is_collection_author_or_atlas_editor
def collection_case_displaysetup(
    request, collection_id, case_number=None, case_id=None
):
    """
    View to set up the display options for a specific case in a collection.
    This can include setting which series to display, annotations, etc.
    """
    # Accept either case_number (index into collection) or case_id (case PK).
    casedetail = None
    try:
        # If a case_id was passed explicitly, try that first
        if case_id is not None:
            casedetail = get_object_or_404(
                CaseDetail, case__pk=case_id, collection__pk=collection_id
            )
        else:
            # Otherwise the route most likely supplied case_number; try resolving by index
            collection = get_object_or_404(CaseCollection, pk=collection_id)
            case_obj = collection.get_case_by_index(int(case_number))
            casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
    except Http404:
        raise
    except Exception:
        raise Http404("Case not found in collection")

    if request.method == "POST":
        data = json.loads(request.body.decode("utf-8"))
        if data.get("reset_viewerstate"):
            casedetail.default_viewerstate = None
            casedetail.save()
            return HttpResponse(
                "<span class='text-success'>Viewer state reset to default.</span>"
            )
        viewerstate = data.get("viewerstate")
        if viewerstate is not None:
            casedetail.default_viewerstate = viewerstate
            casedetail.save()
            return HttpResponse("<span class='text-success'>Viewer state saved.</span>")
        else:
            return HttpResponse(
                "<span class='text-danger'>No viewer state provided.</span>", status=400
            )

    collection = casedetail.collection

    case_number, case_count = collection.get_index_of_case(
        casedetail.case, case_count=True
    )

    previous = collection.get_previous_case(casedetail.case)
    next = collection.get_next_case(casedetail.case)

    return render(
        request,
        "atlas/collection_case_displaysetup.html",
        {
            "casedetail": casedetail,
            "collection": collection,
            "case_number": case_number,
            "case_count": case_count,
            "previous": previous,
            "next": next,
            "collection_length": case_count,
        },
    )


