from functools import lru_cache
import json
import os
import pathlib
from typing import Tuple
import uuid

from django.http import Http404, HttpRequest
from generic.mixins import AuthorMixin, QuestionMixin
from rad.settings import REMOTE_URL
from django.db.models.fields.files import ImageField
from django.db.models.fields.related import ForeignKey
from django.db import models
from django.db.models.signals import pre_save, post_save, post_delete
from django.dispatch import receiver
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.core.cache import cache


import pydicom
import pydicom.multival
from pydicom.errors import InvalidDicomError

from django.core.files.storage import FileSystemStorage
from django.conf import settings
from django.utils.html import format_html, mark_safe

from django.urls import reverse

from django.utils.translation import gettext_lazy as _
from django.utils.html import mark_safe

from django.core.exceptions import ValidationError
from django.contrib.contenttypes.models import ContentType

import string
from collections import defaultdict
from helpers.images import (
    get_image_dicom_hash,
    get_image_hash,
    image_as_base64,
    pretty_print_dicom,
)

import mimetypes


from generic.models import (
    CidUser,
    CidUserExam,
    CidUserGroup,
    CimarCase,
    ExamOrCollectionGenericBase,
    ExamUserStatus,
    Examination,
    # Condition,
    ExamBase,
    FindingBase,
    Plane,
    Contrast,
    QuestionNote,
    SeriesBase,
    SeriesImageBase,
    Modality,
    Site,
    UserUserGroup,
)

# from generic.models import Examination, Site, Condition, Sign

import pydicom.errors
import datetime
from django.utils import timezone

import reversion

from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType

from django.core.validators import MaxValueValidator, MinValueValidator

from loguru import logger



image_storage = FileSystemStorage(
    # Physical file location ROOT
    location="{0}atlas/".format(settings.MEDIA_ROOT),
    # Url for file
    base_url="{0}atlas/".format(settings.MEDIA_URL),
)


def image_directory_path(instance, filename):
    return "atlas/picture/{0}".format(filename)


def uncategorised_dicom_directory_path(instance, filename):
    return "atlas/dicom/{0}".format(filename)


def findMiddle(input_list):
    middle = float(len(input_list)) / 2
    if middle % 2 != 0:
        return input_list[int(middle - 0.5)]
    else:
        return input_list[int(middle)]
        return (input_list[int(middle)], input_list[int(middle - 1)])


def _normalise_group_rule_value(value):
    if value is None:
        return ""

    if isinstance(value, pydicom.multival.MultiValue):
        value = list(value)

    if isinstance(value, (list, tuple)):
        return ",".join([str(v).strip() for v in value if str(v).strip()])

    text = str(value).strip()
    if text == "":
        return ""

    try:
        numeric = float(text)
        if abs(numeric - round(numeric)) < 1e-6:
            return str(int(round(numeric)))
        return f"{numeric:.1f}"
    except (TypeError, ValueError):
        return text


def _series_declared_groups_cache_key(series_id):
    return f"atlas:series_declared_groups:{series_id}"


def invalidate_series_declared_groups_cache(series_id):
    if not series_id:
        return
    try:
        cache.delete(_series_declared_groups_cache_key(series_id))
    except Exception:
        pass


def _build_declared_series_groups(series, series_images_with_urls):
    cache_key = _series_declared_groups_cache_key(series.pk)
    signature = {
        "series_modified": int(series.modified_date.timestamp()) if getattr(series, "modified_date", None) else 0,
        "image_count": len(series_images_with_urls),
        "images": [
            {
                "pk": getattr(image_obj, "pk", None),
                "position": getattr(image_obj, "position", None),
                "url": image_url,
            }
            for image_obj, image_url in series_images_with_urls
        ],
    }
    try:
        cached = cache.get(cache_key)
    except Exception:
        cached = None
    if isinstance(cached, dict):
        if cached.get("signature") == signature:
            return cached.get("groups", [])

    rules = [
        "dwiBValue",
        "dwiDirection",
        "temporalPosition",
        "triggerTime",
        #"contentTime",
        "echoTime",
        "echoNumber",
    ]

    rows = []

    for image_obj, image_url in series_images_with_urls:
        ds = image_obj.get_dicom_data()

        def get_value(*keys):
            for key in keys:
                if key in ds:
                    normalized = _normalise_group_rule_value(ds[key].value)
                    if normalized != "":
                        return normalized
            return ""

        def get_private_b_value():
            # Siemens private (0019,100C)
            try:
                elem = ds.get((0x0019, 0x100C))
                if elem is not None:
                    normalized = _normalise_group_rule_value(getattr(elem, "value", elem))
                    if normalized != "":
                        return normalized
            except Exception:
                pass

            # GE private (0043,1039) can be "b\something" format
            try:
                elem = ds.get((0x0043, 0x1039))
                if elem is not None:
                    raw = getattr(elem, "value", elem)
                    if isinstance(raw, pydicom.multival.MultiValue):
                        raw = raw[0] if len(raw) > 0 else ""
                    text = str(raw).split("\\")[0].strip()
                    normalized = _normalise_group_rule_value(text)
                    if normalized != "":
                        return normalized
            except Exception:
                pass

            return ""

        rule_values = {
            "dwiBValue": get_value("DiffusionBValue", "DiffusionbValue") or get_private_b_value(),
            "dwiDirection": get_value("DiffusionGradientDirection", "DiffusionGradientOrientation"),
            "temporalPosition": get_value("TemporalPositionIdentifier"),
            "triggerTime": get_value("TriggerTime"),
            "contentTime": get_value("ContentTime", "AcquisitionTime"),
            "echoTime": get_value("EchoTime"),
            "echoNumber": get_value("EchoNumbers", "EchoNumber"),
        }

        b_value = None
        try:
            if rule_values["dwiBValue"] != "":
                b_value = float(rule_values["dwiBValue"])
        except (TypeError, ValueError):
            b_value = None

        rows.append(
            {
                "imageId": image_url,
                "ruleValues": rule_values,
                "bValue": b_value,
            }
        )

    if len(rows) == 0:
        return []

    active_rules = []
    for rule_id in rules:
        values = [row["ruleValues"][rule_id] for row in rows if row["ruleValues"][rule_id] != ""]
        distinct = set(values)
        coverage = len(values) / len(rows)
        if len(distinct) > 1 and len(distinct) < max(2, len(rows)) and coverage >= 0.7:
            active_rules.append(rule_id)

    if len(active_rules) == 0:
        result = [
            {
                "key": f"SERVER_SERIES_{series.pk}_0",
                "label": "Series 1",
                "imageIds": [row["imageId"] for row in rows],
                "seriesInstanceUID": series.series_instance_uid,
                "bValue": None,
                "groupValues": {},
            }
        ]
        try:
            cache.set(cache_key, {"signature": signature, "groups": result}, timeout=60 * 60)
        except Exception:
            pass
        return result

    grouped = {}
    for row in rows:
        key_parts = []
        for rule_id in active_rules:
            value = row["ruleValues"][rule_id]
            key_parts.append(f"{rule_id}:{value}" if value != "" else f"{rule_id}:")
        group_key = "|".join(key_parts)

        if group_key not in grouped:
            grouped[group_key] = {
                "imageIds": [],
                "groupValues": {rule_id: row["ruleValues"][rule_id] for rule_id in active_rules if row["ruleValues"][rule_id] != ""},
                "bValue": row["bValue"],
            }

        grouped[group_key]["imageIds"].append(row["imageId"])

        if grouped[group_key]["bValue"] is None and row["bValue"] is not None:
            grouped[group_key]["bValue"] = row["bValue"]

    ordered_groups = []
    for idx, (_, payload) in enumerate(grouped.items()):
        details = []
        group_values = payload.get("groupValues", {})

        if group_values.get("dwiBValue"):
            details.append(f"b={group_values['dwiBValue']}")
        if group_values.get("dwiDirection"):
            details.append(f"dir={group_values['dwiDirection']}")
        if group_values.get("temporalPosition"):
            details.append(f"tp={group_values['temporalPosition']}")
        if group_values.get("triggerTime"):
            details.append(f"tr={group_values['triggerTime']}")
        if group_values.get("contentTime"):
            details.append(f"t={group_values['contentTime']}")
        if group_values.get("echoTime"):
            details.append(f"TE={group_values['echoTime']}")
        if group_values.get("echoNumber"):
            details.append(f"echo={group_values['echoNumber']}")

        label = " • ".join(details) if len(details) > 0 else f"Series {idx + 1}"

        ordered_groups.append(
            {
                "key": f"SERVER_SERIES_{series.pk}_{idx}",
                "label": label,
                "imageIds": payload["imageIds"],
                "seriesInstanceUID": series.series_instance_uid,
                "bValue": payload.get("bValue"),
                "groupValues": group_values,
            }
        )

    try:
        cache.set(cache_key, {"signature": signature, "groups": ordered_groups}, timeout=60 * 60)
    except Exception:
        pass
    return ordered_groups


class SynMixin(object):
    # class Meta:
    #    abstract = True

    def __str__(self) -> str:
        # Determine whether this record should be displayed as the
        # canonical/primary entry. Prefer the new canonical FK pattern
        # (`canonical is None` means this object is the master). Fallback
        # to legacy `primary` boolean when present.
        # If the model supports canonical FK, consider canonical==None as
        # the primary/master entry. For models without canonical (legacy
        # edge-cases), treat the instance as primary by default.
        if hasattr(self, "canonical"):
            try:
                if self.canonical is None or self.canonical == self:
                    return self.name
                return f"{self.name} [syn]"
            except Exception:
                # defensive fallback
                return f"{self.name} [syn]"

        # No canonical support: assume primary
        return self.name

    def get_old_str(self) -> str:
        # Prefer using a model-specific get_synonyms (e.g. Condition.get_synonyms)
        try:
            syns = self.get_synonyms()
        except Exception:
            syns = None
        # If the model supports canonical, only canonical==None entries
        # should be rendered as the primary name. For models without
        # canonical, treat instance as primary.
        if hasattr(self, "canonical"):
            try:
                is_primary = self.canonical is None or self.canonical == self
            except Exception:
                is_primary = False
        else:
            is_primary = True

        if is_primary:
            if syns is None:
                synonyms_field = getattr(self, "synonym", None)
                if synonyms_field is None or (hasattr(synonyms_field, "count") and synonyms_field.count() == 0):
                    return self.name
                synonyms = ",".join([i.name for i in synonyms_field.all()])
                return f"{self.name} ({synonyms})"
            else:
                if not syns:
                    return self.name
                synonyms = ",".join([i.name for i in syns])
                return f"{self.name} ({synonyms})"

        return f"{self.name} [syn]"

    def get_synonym(self):
        # Return a readable synonym string. For models that implement
        # get_synonyms (Condition with canonical/aliases), use that.
        try:
            syns = self.get_synonyms()
        except Exception:
            syns = None
        # If canonical is present and this instance is the master, mark Primary
        if hasattr(self, "canonical"):
            try:
                if self.canonical is None or self.canonical == self:
                    return "[Primary]"
            except Exception:
                pass

        if syns is None:
            synonyms_field = getattr(self, "synonym", None)
            if synonyms_field is None:
                return ""
            # If the old M2M exists, prefer those marked primary if such a
            # attribute exists on the related objects.
            try:
                # Select all synonym names; legacy `primary` flag removed.
                s = synonyms_field.values_list("name", flat=True)
            except Exception:
                s = []
            return ", ".join(s)

        # prefer primary names among synonyms if present
        primary_names = []
        for i in syns:
            # Prefer items that are canonical/master in the group
            if getattr(i, "canonical", None) is None:
                primary_names.append(i.name)
        if primary_names:
            return ", ".join(primary_names)
        return ", ".join([i.name for i in syns])

    def get_synonym_link(self):
        try:
            syns = self.get_synonyms()
        except Exception:
            syns = None
        # Prefer canonical/master check
        if hasattr(self, "canonical"):
            try:
                if self.canonical is None or self.canonical == self:
                    return "[Primary]"
            except Exception:
                pass

        if syns is None:
            # fall back to all M2M synonyms (not just primary) for models
            # that still use the old synonym field (e.g. Finding, Structure)
            synonyms_field = getattr(self, "synonym", None)
            if synonyms_field is None:
                return ""
            syns_qs = synonyms_field.all()
            items = [f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns_qs]
            return ", ".join(items)

        # render links for synonyms/canonical group
        items = [f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns]
        return ", ".join(items)

    def get_link(self):
        return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)


class APIToken(models.Model):
    """Personal Access Token for API access.

    Stores only a hash of the token. The raw token is returned once at
    creation and never stored in plaintext.
    """
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="api_tokens")
    name = models.CharField(max_length=200, blank=True, help_text="Friendly name for this token")
    token_hash = models.CharField(max_length=64, db_index=True)
    scopes = models.CharField(max_length=255, blank=True, help_text="Space-separated scopes")
    created = models.DateTimeField(auto_now_add=True)
    expires = models.DateTimeField(null=True, blank=True)
    revoked = models.BooleanField(default=False)
    last_used = models.DateTimeField(null=True, blank=True)

    class Meta:
        verbose_name = "API token"
        verbose_name_plural = "API tokens"

    def __str__(self):
        return f"APIToken(user={self.user}, name={self.name})"

    @classmethod
    def create_token(cls, user, name="", scopes="", expires=None):
        import secrets, hashlib

        token = secrets.token_urlsafe(32)
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        obj = cls.objects.create(user=user, name=name, token_hash=token_hash, scopes=scopes, expires=expires)
        return token, obj

    def mark_used(self):
        from django.utils import timezone

        self.last_used = timezone.now()
        self.save(update_fields=["last_used"])


class Finding(SynMixin, models.Model):
    name = models.CharField(max_length=255, unique=True)
    # New canonical/alias field: if set, this Finding is an alias and points
    # to the canonical/master Finding. We remove the old M2M `synonym` and
    # `primary` boolean in favour of this single canonical FK.
    canonical = models.ForeignKey(
        "self",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="aliases",
        help_text="If set, this Finding is an alias and points to the canonical Finding.",
    )

    def get_absolute_url(self):
        return reverse("atlas:finding_detail", kwargs={"pk": self.pk})

    @property
    def canonical_finding(self):
        return self.canonical if self.canonical else self

    def get_synonyms(self):
        """Return other Findings that are aliases/synonyms for this concept.

        Behaviour mirrors Condition.get_synonyms: if this Finding is an alias
        (has canonical set) return the canonical and other aliases (excluding
        self). If this Finding is canonical, return all aliases (excluding
        self).
        """
        master = self.canonical_finding
        qs = Finding.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
        return qs


class Condition(SynMixin, models.Model):
    name = models.CharField(max_length=255, unique=True)
    # New canonical/alias field (Option B): if set, this Condition is an alias
    # and points to the canonical/master Condition.
    canonical = models.ForeignKey(
        "self",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="aliases",
        help_text="If set, this Condition is an alias and points to the canonical Condition.",
    )
    parent = models.ManyToManyField(
        "self",
        blank=True,
        related_name="child",
        symmetrical=False,
        through="ConditionRelationship",
        help_text="Use if the condition could be considered a subset of another, e.g. 'Alzheimer disease' and 'Dementia'.",
    )

    # Legacy fields `synonym` (M2M) and `primary` (boolean) have been removed.
    # Canonical/aliases are represented by the `canonical` ForeignKey and
    # the reverse `aliases` related_name. Use `get_synonyms()` /
    # `canonical_condition` to access canonical groups.

    subspecialty = models.ManyToManyField(
        "subspecialty",
        blank=True,
        help_text="Sets the subspecialty(/ies) that this condition falls under.",
    )

    rcr_curriculum_map = models.ManyToManyField(
        "self",
        blank=True,
        limit_choices_to={"rcr_curriculum": True},
        help_text="Specifies the related RCR curriculum condition (if it exists).",
    )

    rcr_curriculum = models.BooleanField(
        default=False,
        help_text="The condition is from the (non exhaustive) RCR curriculum",
    )

    def get_absolute_url(self):
        return reverse("atlas:condition_detail", kwargs={"pk": self.pk})

    @property
    def canonical_condition(self):
        """Return the canonical/master condition for this Condition (self if none)."""
        return self.canonical if self.canonical else self

    def get_synonyms(self):
        """Return other Conditions that are synonyms/aliases for this concept.

        Behaviour:
        - If this Condition is an alias (canonical set), return the canonical and
          any other aliases (excluding self).
        - If this Condition is canonical, return all aliases (and exclude self).
        """
        master = self.canonical_condition
        qs = Condition.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
        return qs

    def get_children(self):
        return self.child.all()

    def get_descendents(self):
        """TODO"""

    def get_parents(self):
        return self.parent.all()

    def get_with_subspecialty(self):
        return f"{', '.join([str(i) for i in self.subspecialty.all()])} / {self.name} "


class ConditionRelationship(models.Model):
    child = models.ForeignKey(Condition, on_delete=models.CASCADE)
    parent = models.ForeignKey(Condition, on_delete=models.CASCADE, related_name="+")

    relationship_type = models.CharField(max_length=255, blank=True, null=True)

    def __str__(self):
        return f"{self.parent} -> {self.child}"


class Presentation(models.Model):
    name = models.CharField(max_length=255)

    subspecialty = models.ManyToManyField("subspecialty", blank=True)

    def __str__(self) -> str:
        if self.subspecialty:
            return (
                f"{self.name} ({', '.join([str(i) for i in self.subspecialty.all()])})"
            )
        return self.name

    def get_absolute_url(self):
        return reverse("atlas:presentation_detail", kwargs={"pk": self.pk})

    def get_link(self):
        return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)


class Subspecialty(models.Model):
    name = models.CharField(max_length=255)

    def __str__(self) -> str:
        return self.name

    def get_absolute_url(self):
        return reverse("atlas:subspecialty_detail", kwargs={"pk": self.pk})

    def get_link(self):
        return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)


class PathologicalProcess(models.Model):
    name = models.CharField(max_length=255)

    def __str__(self) -> str:
        return self.name

    def get_absolute_url(self):
        return reverse("atlas:pathological_process_detail", kwargs={"pk": self.pk})

    def get_link(self):
        return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)


class Procedure(SynMixin, models.Model):
    """A procedure or operation performed on the patient (attachable to Cases).

    This mirrors the lightweight structure used by `Presentation` / `PathologicalProcess`.
    """
    name = models.CharField(max_length=255, unique=True)

    # Optional canonical/alias pointer for future synonym handling
    canonical = models.ForeignKey(
        "self",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="aliases",
        help_text="If set, this Procedure is an alias and points to the canonical Procedure.",
    )

    subspecialty = models.ManyToManyField("subspecialty", blank=True)

    def __str__(self) -> str:
        return self.name

    def get_absolute_url(self):
        return reverse("atlas:procedure_detail", kwargs={"pk": self.pk})

    def get_link(self):
        return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)


class Differential(models.Model):
    condition = models.ForeignKey(Condition, on_delete=models.CASCADE)
    case = models.ForeignKey(
        "Case", on_delete=models.CASCADE, related_name="differentialcase"
    )
    text = models.TextField(null=True, blank=True)


class Structure(SynMixin, models.Model):
    name = models.CharField(max_length=255, unique=True)
    # Migrate to canonical/aliases model like Condition and Finding
    canonical = models.ForeignKey(
        "self",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="aliases",
        help_text="If set, this Structure is an alias and points to the canonical Structure.",
    )

    def get_absolute_url(self):
        return reverse("atlas:structure_detail", kwargs={"pk": self.pk})

    @property
    def canonical_structure(self):
        return self.canonical if self.canonical else self

    def get_synonyms(self):
        master = self.canonical_structure
        qs = Structure.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
        return qs


@reversion.register
class Case(models.Model, AuthorMixin, QuestionMixin):
    # class SubspecialtyChoices(models.TextChoices):
    #    BREAST = "BR", _("Breast")
    #    CARDIAC = "CA", _("Cardiac")
    #    GASTRO = "GI", _("Gastrointestinal and hepatobiliary")
    #    HEADNECK = "HN", _("Head and Neck")
    #    MSK = "MS", _("Musculoskeletal")
    #    NEURO = "NE", _("Neuroradiology")
    #    OBSGYN = "OG", _("Obstectric and Gynaecological")
    #    PAED = "PA", _("Paediatric")
    #    URO = "UR", _("Uroradiology")
    #    VASC = "VA", _("Vascular")
    #    HAEMONC = "HA", _("Haemotology and Oncology")

    class CertaintyChoices(models.IntegerChoices):
        NONE = 0
        POSSIBLE = 1
        LIKELY = 2
        ALMOST_CERTAIN = 3
        CERTAIN = 4

    title = models.CharField(max_length=255, help_text="Title of the case", default="")
    description = models.TextField(
        blank=True,
        help_text="Description of the case",
    )

    history = models.TextField(
        null=True, blank=True, help_text="A (brief) summary of the relevant history"
    )
    discussion = models.TextField(null=True, blank=True)
    report = models.TextField(
        null=True, blank=True, help_text="A model (sample) report for the case."
    )

    # findings = models.TextField(null=True, blank=True)

    # subspecialty = models.CharField(max_length=2, choices=SubspecialtyChoices.choices)
    subspecialty = models.ManyToManyField(
        Subspecialty,
        blank=True,
        help_text="The subspecialties the case is associated with. Multiple subspecialties can be selected.",
    )

    condition = models.ManyToManyField(
        Condition, blank=True, help_text="The condition(s) the case demonstrates."
    )

    presentation = models.ManyToManyField(
        Presentation,
        blank=True,
        help_text="The presentation(s) the case is associated with.",
    )

    # Procedures performed on the patient (e.g. operations, interventions)
    procedures = models.ManyToManyField(
        "Procedure",
        blank=True,
        help_text="Procedure(s) or operations the patient has undergone.",
    )

    pathological_process = models.ManyToManyField(PathologicalProcess, blank=True)

    differential = models.ManyToManyField(
        Condition, through=Differential, related_name="casedifferential",
        help_text="The differential diagnosis for the case.",
    )

    diagnostic_certainty = models.IntegerField(
        choices=CertaintyChoices.choices, default=CertaintyChoices.NONE,
        help_text="The diagnostic certainty of the case.",
    )

    verified = models.BooleanField(default=False)
    created_date = models.DateTimeField(default=timezone.now)
    published_date = models.DateTimeField(blank=True, null=True)
    author = models.ManyToManyField(
        settings.AUTH_USER_MODEL,
        blank=True,
        help_text="Author of the case",
        related_name="atlas_authored_cases",
    )

    editor = models.ManyToManyField(
        settings.AUTH_USER_MODEL,
        blank=True,
        related_name="atlas_edited_cases",
    )

    archive = models.BooleanField(
        default=False,
        help_text="Case has been archived, this will remain on the system but will not be shown",
    )

    open_access = models.BooleanField(
        help_text="If a case should be freely available to browse", default=True
    )

    series = models.ManyToManyField(
        "Series", through="SeriesDetail", related_name="case"
    )

    notes = GenericRelation(QuestionNote)

    previous_case = models.OneToOneField(
        "Case",
        on_delete=models.SET_NULL,
        null=True,
        related_name="next_case",
        blank=True,
        help_text="If this case is related to another case on the system (e.g. follow up), link them here.",
    )

    resource = models.ManyToManyField("Resource", through="CaseResource")

    total_images_series_size = models.BigIntegerField(null=True, blank=True)

    cimar_uuid = models.CharField(max_length=255, null=True, blank=True)

    study_date = models.DateField(
        null=True,
        blank=True,
        help_text="Optional manually overridden study date for linked case series.",
    )

    def get_series_info(self):
        try:
            has_next = hasattr(self, 'next_case') and self.next_case is not None
        except Case.DoesNotExist:
            has_next = False

        if not self.previous_case_id and not has_next:
            return {"in_series": False}

        # Find the start of the chain (the first case)
        first_case = self
        visited = {self.pk}
        while first_case.previous_case:
            if first_case.previous_case.pk in visited:
                break
            first_case = first_case.previous_case
            visited.add(first_case.pk)

        # Traverse the chain from the first case to find positions and count
        chain = []
        current = first_case
        visited_forward = set()
        while current:
            if current.pk in visited_forward:
                break
            chain.append(current)
            visited_forward.add(current.pk)
            try:
                current = current.next_case if hasattr(current, 'next_case') else None
            except Case.DoesNotExist:
                current = None

        total = len(chain)
        try:
            position = chain.index(self) + 1
        except ValueError:
            position = 1

        return {
            "in_series": True,
            "position": position,
            "total": total,
        }

    def get_ordered_series_details(self):
        cached = getattr(self, "_ordered_series_details_cache", None)
        if cached is not None:
            return cached

        prefetched = getattr(self, "_prefetched_objects_cache", {})
        if "seriesdetail_set" in prefetched:
            through_objs = sorted(
                list(prefetched["seriesdetail_set"]),
                key=lambda item: item.sort_order,
            )
            self._ordered_series_details_cache = through_objs
            return through_objs

        through_objs = list(
            self.series.through.objects
            .filter(case=self)
            .select_related("series")
            .order_by("sort_order")
        )
        self._ordered_series_details_cache = through_objs
        return through_objs

    def get_ordered_series(self):
        """Returns the series in the case in order of the SeriesDetail sort_order"""
        cached = getattr(self, "_ordered_series_cache", None)
        if cached is not None:
            return cached

        ordered = [sd.series for sd in self.get_ordered_series_details()]
        self._ordered_series_cache = ordered
        return ordered

    def get_case_named_stacks(self):
        stacks = []
        for series in self.get_ordered_series():
            series_images = list(series.get_images())
            series_images_with_urls = [(img, f"{REMOTE_URL}{img.image.url}") for img in series_images]
            images = [url for _, url in series_images_with_urls]
            declared_groups = _build_declared_series_groups(series, series_images_with_urls)
            stacks.append(
                {
                    "name": str(series),
                    "imageIds": images,
                    "seriesId": series.pk,
                    "series": declared_groups,
                }
            )
        results = [
            {
                "caseId": f"CASE-{self.pk}",
                "studyId": "Current Case",
                "stacks": stacks,
            }
        ]
        return json.dumps(results)

    @staticmethod
    def _parse_dicom_date(value):
        if not value:
            return None
        try:
            return datetime.datetime.strptime(str(value), "%Y%m%d").date()
        except (TypeError, ValueError):
            return None

    def _get_series_study_date(self):
        for series in self.get_ordered_series():
            image = series.images.filter(removed=False).first()
            if not image:
                continue

            tags = getattr(image, "basic_dicom_tags", None) or {}
            raw_date = (
                tags.get("StudyDate")
                or tags.get("AcquisitionDate")
                or tags.get("SeriesDate")
            )
            parsed = self._parse_dicom_date(raw_date)
            if parsed:
                return parsed

            try:
                ds = image.get_dicom_data()
            except Exception:
                ds = None
            if not ds:
                continue

            raw_date = (
                getattr(ds, "StudyDate", None)
                or getattr(ds, "AcquisitionDate", None)
                or getattr(ds, "SeriesDate", None)
            )
            parsed = self._parse_dicom_date(raw_date)
            if parsed:
                return parsed

        return None

    def get_effective_study_date(self):
        if self.study_date:
            return self.study_date

        return self._get_series_study_date()

    def save(self, *args, **kwargs):
        if self.pk and not self.study_date:
            derived_study_date = self._get_series_study_date()
            if derived_study_date:
                self.study_date = derived_study_date

        super().save(*args, **kwargs)

    def get_app_name(self):
        return "atlas"

    # question_file = models.FileField(upload_to=question_file_directory_path, blank=True, null=True)

    def get_absolute_url(self):
        return reverse("atlas:case_detail", kwargs={"pk": self.pk})

    def get_link(self):
        # return f"{self.pk}: {self.title}"
        return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), str(self))

    def get_cimar_case(self, raise_404_if_not_found=False):
        if raise_404_if_not_found:
            return get_object_or_404(CimarCase, uuid=self.cimar_uuid)
        
        try:
            return CimarCase.objects.get(uuid=self.cimar_uuid)
        except CimarCase.DoesNotExist:
            return None

    def get_cimar_case_details(self):
        cimar_case = self.get_cimar_case(raise_404_if_not_found=True)
        return cimar_case.study_details


    # def get_base_template(self):

    # def get_edit_template_links(self):

    def __str__(self):
        if self.open_access:
            return f"{self.pk}: {self.title} [OA]"
        else:
            return f"{self.pk}: {self.title}"

    def get_series_blocks(self):
        html = ""
        s: Series
        for s in self.series.all():
            html += s.get_block()

        return mark_safe(html)

    def get_long_str(self):
        examinations = [series.get_examination() for series in self.series.all()]
        return f"{self.pk}:{self.title} {'/'.join([str(c) for c in self.condition.all()])} [{', '.join(examinations)}]"

    def get_case_dicom_json(self, case_title_as_patient_name=True, priors=None):
        series_json = []
        series: Series
        for series in self.series.all():
            series_json.append(series.get_series_dicom_json())

        patient_name = ""
        if case_title_as_patient_name:
            patient_name = self.title

        studies_json = {
            "studies": [
                {
                    "StudyInstanceUID": "1.3.6.1.4.1.14519.5.2.1.6279.6001.298806137288633453246975630178",
                    #"StudyDate": "20000101",
                    "StudyTime": "",
                    "PatientName": patient_name,
                    "PatientID": "LIDC-IDRI-0001",
                    "AccessionNumber": "",
                    "PatientAge": "",
                    "PatientSex": "",
                    "series": series_json,
                }
            ]

        }

        if priors is not None:
            for prior in priors:
                series_json = []
                for series in prior.prior_case.series.all():
                    series_json.append(series.get_series_dicom_json())
                studies_json["studies"].append(
                {
                    "StudyInstanceUID": "1.3.6.1.4.1.14519.5.2.1.6279.6001.298806137288633453246975630177",
                    #"StudyDate": "20000101",
                    "StudyTime": "",
                    "PatientName": patient_name,
                    "PatientID": "LIDC-IDRI-0001",
                    "AccessionNumber": "",
                    "PatientAge": "",
                    "PatientSex": "",
                    "series": series_json,
                    "StudyDescription": f"Prior: {prior.relation_text}"
                }
            )

        return studies_json

    def get_viva_details(self):
        return {
            "title": self.title,
            "description": self.description,
            "history": self.history,
            "discussion": self.discussion,
            "report": self.report,
        }

    def get_viva_details_json(self):
        return json.dumps(self.get_viva_details())

    def get_all_prior_cases(self):
        prior_cases = []

        while self.previous_case is not None:
            prior_cases.append(self.previous_case)
            self = self.previous_case

        return prior_cases

    def get_all_future_cases(self):
        from django.core.exceptions import ObjectDoesNotExist
        future_cases = []
        current = self
        while True:
            try:
                next_c = current.next_case
            except ObjectDoesNotExist:
                break
            if next_c is None:
                break
            future_cases.append(next_c)
            current = next_c
        return future_cases

    def get_series(self):
        return self.series.all().prefetch_related("images", "examination", "plane")

    def get_series_modalities(self) -> set:
        """Returns a list of the modalities of the series in the case"""
        series = self.series.all()
        modalities = [s.modality.short_code for s in series if s.modality is not None]

        return set(modalities)


    def get_total_series_images_size(self, refresh=False):
        if self.total_images_series_size is not None and not refresh:
            return self.total_images_series_size

        series = self.series.all()
        size = sum(s.get_total_image_size(refresh=refresh) for s in series)

        return size

    def check_user_can_edit(self, user) -> bool:
        """Check if the user can edit the case.

        Args:
            user (User): The user to check.

        Returns:
            bool: True if the user can edit the case, False otherwise.
        """
        if user.is_superuser:
            return True

        if self.author.filter(id=user.id).exists():
            return True

        return False

    def get_series_images_nested(self, as_json: bool =True, exclude_series_ids: None | list[int] = None):
        """
        Returns a list of lists, where each inner list contains the image URLs
        for a single series in this case, in the order of the series.
        """

        series_qs = self.series.all()
        if exclude_series_ids is not None:
            series_qs = series_qs.exclude(id__in=exclude_series_ids)

        # Ensure series are ordered by the through model sort_order
        series_list = list(series_qs.order_by('seriesdetail__sort_order').prefetch_related('images'))

        images = []
        for series in series_list:
            # If images were prefetched, use the cached list and filter in Python
            prefetched = getattr(series, '_prefetched_objects_cache', {}).get('images')
            if prefetched is not None:
                imgs = [img.image.url for img in prefetched if not getattr(img, 'removed', False)]
            else:
                imgs = [image.image.url for image in series.images.filter(removed=False)]
            images.append(imgs)

        if as_json:
            return json.dumps(images)
        else:
            return images

    #def get_case_named_stacks(self):
    #    def build_stacks_for(case_obj, prefix=None):
    #        logger.debug(f"Building stacks for case {case_obj.pk} with prefix '{prefix}'")
    #        stacks = []
    #        for series in case_obj.get_ordered_series():
    #            series_images = list(series.get_images())
    #            series_images_with_urls = [(img, f"{REMOTE_URL}{img.image.url}") for img in series_images]
    #            images = [url for _, url in series_images_with_urls]
    #            declared_groups = _build_declared_series_groups(series, series_images_with_urls)
    #            name = f"{prefix}: {series}" if prefix else str(series)
    #            stacks.append(
    #                {
    #                    "name": name,
    #                    "imageIds": images,
    #                    "seriesId": series.pk,
    #                    "series": declared_groups,
    #                }
    #            )
    #        return stacks

    #    results = []

    #    logger.debug(f"Building stacks for case {self.pk}")

    #    # main case entry
    #    results.append(
    #        {
    #            "caseId": f"",
    #            "studyId": f"Current Case",
    #            "stacks": build_stacks_for(self, prefix=None),
    #        }
    #    )

    #    return json.dumps(results)


class NormalCase(models.Model, AuthorMixin):
    """Marks a Case as a 'normal' example used in the Atlas normals section.

    This version stores a canonical `age_days` integer (patient age in days).
    We keep the examination/modality metadata and authored fields. The
    age fields previously stored (`age_years`, `age_value`, `age_unit`) have
    been removed in favour of the single canonical `age_days` value.
    """
    case = models.OneToOneField(
        Case,
        on_delete=models.CASCADE,
        related_name="normal_case",
        help_text="The Case that is marked as a normal example.",
    )

    # Canonical internal representation: patient age in days
    age_days = models.PositiveIntegerField(
        null=True,
        blank=True,
        help_text="Patient age at scan in days (canonical).",
    )

    # Helpful constants for converting/displaying ages. These are not
    # stored directly on the model but are used by forms/views/templates.
    AGE_UNIT_YEARS = "years"
    AGE_UNIT_MONTHS = "months"
    AGE_UNIT_WEEKS = "weeks"
    AGE_UNIT_DAYS = "days"
    AGE_UNIT_CHOICES = [
        (AGE_UNIT_YEARS, "years"),
        (AGE_UNIT_MONTHS, "months"),
        (AGE_UNIT_WEEKS, "weeks"),
        (AGE_UNIT_DAYS, "days"),
    ]

    examination = models.ForeignKey(
        Examination,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="normal_cases",
        help_text="Primary examination for this normal case (optional).",
    )

    modality = models.ForeignKey(
        Modality,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="normal_cases",
        help_text="Primary modality for this normal case (optional).",
    )

    added_date = models.DateTimeField(default=timezone.now)


    author = models.ManyToManyField(
        settings.AUTH_USER_MODEL,
        blank=True,
        related_name="normal_cases",
    )

    notes = models.TextField(null=True, blank=True)


    class Meta:
        ordering = ["-added_date"]

    def __str__(self):
        return f"Normal: {self.case} ({self.display_age()})"

    def get_absolute_url(self):
        return self.case.get_absolute_url()

    @staticmethod
    def to_days(value: int | float, unit: str) -> int | None:
        """Convert a numeric value + unit into integer days (rounded).

        Examples:
          to_days(2, 'years') -> ~730
          to_days(6, 'months') -> ~183
        """
        try:
            if value is None or unit is None:
                return None
            v = float(value)
            if unit == NormalCase.AGE_UNIT_YEARS:
                return int(round(v * 365.25))
            if unit == NormalCase.AGE_UNIT_MONTHS:
                return int(round(v * 30.4375))
            if unit == NormalCase.AGE_UNIT_WEEKS:
                return int(round(v * 7))
            if unit == NormalCase.AGE_UNIT_DAYS:
                return int(round(v))
        except Exception:
            return None

    @staticmethod
    def days_to_value_unit(days: int | None) -> tuple[int | None, str | None]:
        """Convert canonical days into a (value, unit) tuple for display.

        Prefer years for ages >= 365 days, months for ages >= 30 days,
        weeks for >=7 days, else days.
        """
        if days is None:
            return (None, None)
        try:
            d = int(days)
            if d >= 365:
                yrs = int(round(d / 365.25))
                return (yrs, NormalCase.AGE_UNIT_YEARS)
            if d >= 30:
                months = int(round(d / 30.4375))
                return (months, NormalCase.AGE_UNIT_MONTHS)
            if d >= 7:
                weeks = int(round(d / 7))
                return (weeks, NormalCase.AGE_UNIT_WEEKS)
            return (d, NormalCase.AGE_UNIT_DAYS)
        except Exception:
            return (None, None)

    def display_age(self) -> str:
        """Return a human friendly age string derived from age_days."""
        if self.age_days is None:
            return "age unknown"
        val, unit = NormalCase.days_to_value_unit(self.age_days)
        if val is None or unit is None:
            return "age unknown"
        # abbreviate years to 'y' for backwards compatibility where helpful
        if unit == NormalCase.AGE_UNIT_YEARS:
            return f"{val}y"
        return f"{val} {unit}"

    def save(self, *args, **kwargs):
        # If examination/modality not specified, try to infer from the first series
        try:
            if (self.examination is None or self.modality is None) and self.case.series.exists():
                first_series = self.case.series.first()
                if self.examination is None and getattr(first_series, 'examination', None) is not None:
                    self.examination = first_series.examination
                if self.modality is None and getattr(first_series, 'modality', None) is not None:
                    self.modality = first_series.modality
        except Exception:
            # Be defensive: if anything goes wrong whilst inferring, continue and save as-is
            pass

        super().save(*args, **kwargs)

def extract_image_dicom_json_from_ds(ds, url, image_index):
    to_keep = [
        "Columns",
        "Rows",
        "InstanceNumber",
        "SOPClassUID",
        "PhotometricInterpretation",
        "BitsAllocated",
        "BitsStored",
        "PixelRepresentation",
        "SamplesPerPixel",
        "PixelSpacing",
        "HighBit",
        "ImageOrientationPatient",
        "ImagePositionPatient",
        "FrameOfReferenceUID",
        "ImageType",
        "Modality",
        "SOPInstanceUID",
        "SeriesInstanceUID",
        "StudyInstanceUID",
        "WindowCenter",
        "WindowWidth",
        "SeriesDate",
    ]

    d = {}

    for key in to_keep:
        if key in ds:
            val = ds[key].value

            if type(val) == pydicom.multival.MultiValue:
                d[key] = list(val)
            else:
                d[key] = val

    # Is it worth trying on fake dicom tags?.....
    if d == {}:
        d["SOPInstanceUID"] = f"1.2.840.1111.{image_index}"
        d["SeriesInstanceUID"] = f"1.2.840.1112.1"

    return {"metadata": d, "url": f"dicomweb:{url}"}


class SeriesImage(SeriesImageBase):
    image = models.FileField(upload_to=image_directory_path, null=True)
    series = models.ForeignKey(
        "Series", related_name="images", on_delete=models.CASCADE, null=True
    )

    replaced = models.ForeignKey(
        "self",
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        help_text="Reference to the object that has replaced this one.",
    )

    # def get_image_dicom_json(self, image_index):
    #    try:
    #        with pydicom.dcmread(self.image) as ds:
    #            return extract_image_dicom_json_from_ds(ds, url=f"{REMOTE_URL}{self.image.url}")
    #    except FileNotFoundError:
    #        return []
    #    except InvalidDicomError:
    #        return {"url": f"dicomweb:{REMOTE_URL}{self.image.url}", "metadata": {
    #            "SOPInstanceUID" : f"1.2.840.1111.{image_index}"
    #        }}

@receiver(pre_save, sender=SeriesImage)
def _capture_previous_series_for_series_image(sender, instance, **kwargs):
    if not getattr(instance, "pk", None):
        instance._previous_series_id_for_cache = None
        return
    previous_series_id = (
        sender.objects.filter(pk=instance.pk).values_list("series_id", flat=True).first()
    )
    instance._previous_series_id_for_cache = previous_series_id


def _invalidate_and_touch_series(series_id):
    if not series_id:
        return
    invalidate_series_declared_groups_cache(series_id)
    Series.objects.filter(pk=series_id).update(modified_date=timezone.now())


@receiver(post_save, sender=SeriesImage)
def _invalidate_declared_groups_on_series_image_save(sender, instance, **kwargs):
    affected_series_ids = set()
    if instance.series_id:
        affected_series_ids.add(instance.series_id)
    previous_series_id = getattr(instance, "_previous_series_id_for_cache", None)
    if previous_series_id:
        affected_series_ids.add(previous_series_id)

    for series_id in affected_series_ids:
        _invalidate_and_touch_series(series_id)


@receiver(post_delete, sender=SeriesImage)
def _invalidate_declared_groups_on_series_image_delete(sender, instance, **kwargs):
    _invalidate_and_touch_series(getattr(instance, "series_id", None))


class SeriesFinding(FindingBase):
    series = models.ForeignKey(
        "Series", related_name="findings", on_delete=models.SET_NULL, null=True
    )
    findings = models.ManyToManyField(Finding, blank=True)
    structures = models.ManyToManyField(Structure, blank=True)
    conditions = models.ManyToManyField(Condition, blank=True)

    def __str__(self) -> str:
        findings = self.findings.all().values_list("name")
        if self.series is None:
            return f"SeriesFinding: {findings}/{self.description} (no series)"
        return f"SeriesFinding: {self.series.id}/{findings}/{self.description}"

@reversion.register
class Series(SeriesBase):
    modality = models.ForeignKey(
        Modality,
        related_name="atlas_series_modality",
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
    )
    examination = models.ForeignKey(
        Examination,
        help_text="Name of the examination",
        related_name="atlas_series_examination",
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
    )
    plane = models.ForeignKey(
        Plane,
        help_text="Plane of the examination",
        related_name="atlas_series_plane",
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
    )
    contrast = models.ForeignKey(
        Contrast,
        help_text="MRI / CT contrast",
        related_name="atlas_series_contrast",
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
    )

    author = models.ManyToManyField(
        settings.AUTH_USER_MODEL,
        blank=True,
        related_name="series",
    )

    series_instance_uid = models.CharField(max_length=255, blank=True, null=True)
    source_series_instance_uid = models.CharField(max_length=255, blank=True, null=True, db_index=True)
    study_instance_uid = models.CharField(max_length=255, blank=True, null=True, db_index=True)

    # findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack")
    def __str__(self) -> str:
        if self.description:
            return self.description
        else:
            return f"{self.examination} ({self.plane})"
    
    def user_can_edit(self, user, allow_if_can_edit_cases=True) -> bool:
        """Check if the user can edit the series.

        Args:
            user (User): The user to check.
        Returns:
            bool: True if the user can edit the series, False otherwise.
        """
        if user.is_superuser:
            return True

        if self.author.filter(id=user.id).exists():
            return True

        if allow_if_can_edit_cases:
            # Check if user can edit any of the cases this series is in
            for case in self.case.all():
                if case.check_user_can_edit(user):
                    return True
        return False

    def get_full_str(self):
        if self.case:
            case_id = ", ".join([str(case) for case in self.case.all()])
            # case_id = self.case.pk
        else:
            case_id = "None"
        return "{}/{} : {} [{}]".format(
            self.pk, self.get_examination_full(), self.description, case_id
        )

    def get_viewer_stack_description(self) -> str:
        description = self.description or str(self.examination or "Series")
        if self.plane:
            description = f"{description} ({self.plane})"
        if self.contrast:
            description = f"{description} / {self.contrast}"
        return description

    def get_absolute_url(self):
        return reverse("atlas:series_detail", kwargs={"pk": self.pk})

    def get_link(self):
        return format_html(
            "<a href='{}'>{}</a>", self.get_absolute_url(), self.get_full_str()
        )

    # @lru_cache(maxsize=128)
    def get_series_dicom_json(self):
        instances = []

        series_json = {}

        to_keep = ["SeriesInstanceUID", "SeriesNumber", "Modality", "SliceThickness"]

        # TODO: clean up (this is rather convoluted....)
        image: SeriesImage
        for series_n, image in enumerate(self.images.filter(removed=False)):
            ds = image.get_dicom_data()
            if series_n == 0:
                for tag in to_keep:
                    if tag in ds:
                        val = ds[tag].value

                        if type(val) == pydicom.multival.MultiValue:
                            series_json[tag] = list(val)
                        else:
                            series_json[tag] = val

            instances.append(
                extract_image_dicom_json_from_ds(
                    ds, url=f"{REMOTE_URL}{image.image.url}", image_index=series_n
                )
            )
            # else:
            #    instances.append(image.get_image_dicom_json(image_index))

        series_json["SeriesDescription"] = self.get_viewer_stack_description()
        series_json["instances"] = instances

        return series_json

    def get_ohif_dicom_json(self):
        series_json = []
        series_json.append(self.get_series_dicom_json())

        patient_name = ""

        return {
            "studies": [
                {
                    "StudyInstanceUID": "1.3.6.1.4.1.14519.5.2.1.6279.6001.298806137288633453246975630178",
                    "StudyDate": "20000101",
                    "StudyTime": "",
                    "PatientName": patient_name,
                    "PatientID": "LIDC-IDRI-0001",
                    "AccessionNumber": "",
                    "PatientAge": "",
                    "PatientSex": "",
                    "series": series_json,
                }
            ]
        }

    def use_date_as_description(self):
        # get first image
        image = self.images.first()

        with pydicom.dcmread(image.image) as ds:
            date = ds.get("StudyDate", "No date")

        self.description = f"{date[:4]}-{date[4:6]}-{date[6:]}"

        self.save()

    def get_base_template(self):
        return "atlas/base.html"

    def get_link_headers(self):
        return "atlas/series_headers.html"

    def get_related_findings(self):
        """Returns the related findings for the series, including findings from other series in the same case."""
        # Get findings directly related to this series
        findings = list(self.findings.all())

        # Get findings from other series in the same case
        if hasattr(self, "case") and self.case.exists():
            for case in self.case.all():
                for other_series in case.series.exclude(pk=self.pk):
                    findings.extend(other_series.findings.all())

        # Remove duplicates (by pk)
        unique_findings = {f.pk: f for f in findings}.values()
        return list(unique_findings)



class CaseCollection(ExamOrCollectionGenericBase):

    app_name = "atlas"

    cases = models.ManyToManyField(Case, through="CaseDetail")

    show_title_pre = models.BooleanField(
        default=False, help_text="Show the title of the cases (pre exam)"
    )
    show_history_pre = models.BooleanField(
        default=False, help_text="Show the history of the cases (pre exam)"
    )
    show_description_pre = models.BooleanField(
        default=False, help_text="Show the description of the cases (pre exam)"
    )
    show_discussion_pre = models.BooleanField(
        default=False, help_text="Show the case discussion (pre exam)"
    )
    show_report_pre = models.BooleanField(
        default=False, help_text="Show the case report (pre exam)"
    )

    show_title_post = models.BooleanField(
        default=False, help_text="Show the title of the cases (post exam)"
    )
    show_history_post = models.BooleanField(
        default=False, help_text="Show the history of the cases (post exam)"
    )
    show_description_post = models.BooleanField(
        default=False, help_text="Show the description of the cases (post exam)"
    )
    show_discussion_post = models.BooleanField(
        default=False, help_text="Show the case discussion (post exam)"
    )
    show_report_post = models.BooleanField(
        default=False, help_text="Show the case report (post exam)"
    )

    # New post-exam display options
    show_case_link_post = models.BooleanField(
        default=False, help_text="Show a link to the case (post exam)"
    )
    show_findings_post = models.BooleanField(
        default=False, help_text="Show findings related to the case (post exam)"
    )
    show_displaysets_post = models.BooleanField(
        default=False, help_text="Show display sets for the case (post exam)"
    )
    show_differentials_post = models.BooleanField(
        default=False, help_text="Show differentials for the case (post exam)"
    )


    author = models.ManyToManyField(
        settings.AUTH_USER_MODEL,
        blank=True,
        help_text="Author of the collection",
        related_name="casecollection_authored_cases",
    )

    markers = models.ManyToManyField(
        settings.AUTH_USER_MODEL,
        blank=True,
        help_text="Authorised markers for the exam",
        related_name="casecollection_markers",
    )

    valid_cid_users = models.ManyToManyField(
        CidUser, blank=True, related_name="casecollection_exams"
    )

    valid_user_users = models.ManyToManyField(
        settings.AUTH_USER_MODEL, blank=True, related_name="user_casecollection_exams"
    )

    cid_user_groups = models.ManyToManyField(
        CidUserGroup,
        blank=True,
        help_text="These groups define which candidates are able to be added to the exams/collection.",
        related_name="casecollection_cid_user_groups",
    )

    user_user_groups = models.ManyToManyField(
        UserUserGroup,
        blank=True,
        help_text="These groups define which candidates are able to be added to the exams/collection.",
        related_name="casecollection_user_user_groups",
    )

    exam_mode = models.BooleanField(default=False)

    # This should override the publish setting
    self_review = models.BooleanField(
        default=False,
        help_text="If true allows users self complete and review cases in a self directed way.",
    )

    exam_user_status = GenericRelation(ExamUserStatus, related_query_name="atlas_exams")
    cid_user_exam = GenericRelation("generic.CidUserExam", related_query_name="atlas_exams")

    feedback_once_collection_complete = models.BooleanField(default=True, help_text="If true feedback is only given once the collection is complete. If false feedback is given after each case.")
    case_query_messaging_enabled = models.BooleanField(
        default=True,
        help_text="Enable per-case query and messaging for this collection.",
    )
    show_results_sharing_information = models.BooleanField(
        default=True,
        help_text="If true, show result-sharing information on candidate start and overview screens.",
    )

    question_time_limit = models.PositiveIntegerField(
        blank=True,
        null=True,
        help_text="Time limit for answering questions in seconds."
    )

    answer_entry_grace_period = models.PositiveIntegerField(
        blank=True,
        null=True,
        default=0,
        help_text="Additional seconds after the case view is locked during which answers can still be entered."
    )

    start_screen_content = models.TextField(
        blank=True,
        null=True,
        help_text="Optional HTML content shown on the collection start screen (for example embedded resources).",
    )

    start_screen_resources = models.ManyToManyField(
        "Resource",
        blank=True,
        related_name="start_screen_collections",
        help_text="Optional resources shown on the collection start screen.",
    )

    end_overview_content = models.TextField(
        blank=True,
        null=True,
        help_text="Optional HTML content shown on the collection end overview screen.",
    )

    end_overview_resources = models.ManyToManyField(
        "Resource",
        blank=True,
        related_name="end_overview_collections",
        help_text="Optional resources shown on the collection end overview screen.",
    )

    # Collections that must be completed before this collection can be taken
    prerequisites = models.ManyToManyField(
        "self",
        blank=True,
        symmetrical=False,
        related_name="dependents",
        help_text="Collections that must be completed before this collection can be taken",
    )

    class COLLECTION_TYPE_CHOICES(models.TextChoices):
        REVIEW = (
            "REV",
            _("Review"),
        )
        REPORT = (
            "REP",
            _("Report"),
        )
        QUESTION = (
            "QUE",
            _("Question"),
        )
        VIVA = (
            "VIV",
            _("Viva"),
        )

    collection_type = models.CharField(
        max_length=3,
        choices=COLLECTION_TYPE_CHOICES.choices,
        default=COLLECTION_TYPE_CHOICES.REPORT,
    )

    class VIEWER_MODE_CHOICES(models.TextChoices):
        BUILT_IN = (
            "BUILT_IN",
            _("Built in viewer"),
        )
        OHIF = (
            "OHIF",
            _("OHIF Viewer"),
        )
        BUILT_IN_WITH_OHIF = (
            "BUILT_IN_WITH_OHIF",
            _("Built in viewer with OHIF Viewer option"),
        )

    viewer_mode = models.CharField(
        max_length=100,
        choices=VIEWER_MODE_CHOICES.choices,
        default=VIEWER_MODE_CHOICES.BUILT_IN,
    )

    # -----------------------------------------------------------------------
    # Marking configuration
    # These fields can be defined here so that the same CaseCollection can be
    # reused in multiple research studies or standalone with consistent marking.
    # -----------------------------------------------------------------------
    marking_scheme_name = models.CharField(
        max_length=255,
        blank=True,
        help_text="Human-readable mark scheme name shown to markers and in exports.",
    )
    marking_guidance = models.TextField(
        blank=True,
        help_text="Plain-language marking guidance displayed to markers.",
    )
    case_question_max_score = models.PositiveIntegerField(
        null=True,
        blank=True,
        help_text="Maximum score per case used for normalisation. Defaults to 10 when not set.",
    )

    # -----------------------------------------------------------------------
    # Case question template
    # When set, this JSON schema is used as a default for all CaseDetail entries
    # in the collection that do not have their own question_schema.
    # -----------------------------------------------------------------------
    case_question_template = models.JSONField(
        null=True,
        blank=True,
        help_text=(
            "JSON schema applied as a default question template for all cases in this collection. "
            "Individual cases can override via CaseDetail.question_schema."
        ),
    )
    auto_apply_question_template = models.BooleanField(
        default=False,
        help_text=(
            "If enabled, the case_question_template is automatically applied to new cases "
            "added to this collection that do not have an existing question_schema."
        ),
    )

    def get_app_name(self):
        return "atlas"

    def get_base_template(self):
        return "atlas/base.html"

    def get_link_headers(self):
        return f"{self.get_app_name()}/collection_headers.html"

    def get_absolute_url(self):
        return reverse("atlas:collection_detail", kwargs={"pk": self.pk})

    def get_take_url(self):
        return reverse("atlas:collection_take_start", kwargs={"pk": self.pk})
    
    def get_take_url_exam_mode(self):
        return reverse("atlas:collection_take_start", kwargs={"pk": self.pk})


    def __str__(self) -> str:
        return self.name

    def show_built_in_viewer(self) -> bool:
        return self.viewer_mode in (
            self.VIEWER_MODE_CHOICES.BUILT_IN,
            self.VIEWER_MODE_CHOICES.BUILT_IN_WITH_OHIF,
        )

    def show_ohif_viewer(self) -> bool:
        return self.viewer_mode in (
            self.VIEWER_MODE_CHOICES.OHIF,
        )

    def show_ohif_viewer_link(self) -> bool:
        return self.viewer_mode in (
            self.VIEWER_MODE_CHOICES.BUILT_IN_WITH_OHIF,
        )

    def review_only(self) -> bool:
        """Returns True if a users cannot submit responses

        Returns:
            bool: _description_
        """
        if self.collection_type == self.COLLECTION_TYPE_CHOICES.REVIEW:
            return True

        return False

    def in_review_mode(self) -> bool:
        return self.review_only() or self.publish_results

    # def can_answer(self) -> bool:
    #    """Returns true if the user is able to answer questions in a collection
    #    (independent of current collection state (e.g. published / not published))"""
    #    if self.collection_type == self.COLLECTION_TYPE_CHOICES.REPORT:
    #        return True

    #    return False

    def get_cases(self):
        """Returns the cases in the collection in order of the CaseDetail sort_order"""
        return self.cases.all().order_by("casedetail__sort_order")

    def get_effective_case_time_limit(self, casedetail):
        if casedetail.question_time_limit_override is not None:
            return casedetail.question_time_limit_override
        return self.question_time_limit

    def get_effective_answer_entry_grace_period(self, casedetail):
        if casedetail.answer_entry_grace_period_override is not None:
            return casedetail.answer_entry_grace_period_override
        return self.answer_entry_grace_period or 0

    def get_next_case(self, case):
        cases = list(self.get_cases())

        try:
            return cases[cases.index(case) + 1]
        except IndexError:
            return None

    def get_previous_case(self, case):
        cases = list(self.get_cases())

        new_index =  cases.index(case) - 1

        if new_index < 0:
            return None
        else:
            return cases[new_index]

    def apply_question_template_to_cases(self, overwrite_existing=False):
        """
        Apply case_question_template to all CaseDetail entries in this collection.

        Args:
            overwrite_existing: If True, overwrite existing question_schema on CaseDetails.
                                 If False (default), only apply to cases without a schema.

        Returns:
            int: Number of CaseDetail entries updated.
        """
        if not self.case_question_template:
            return 0

        casedetails = self.casedetail_set.all()
        updated = 0
        for cd in casedetails:
            if overwrite_existing or not cd.question_schema:
                cd.question_schema = self.case_question_template
                cd.save(update_fields=["question_schema"])
                updated += 1
        return updated

    def get_effective_question_schema(self, casedetail):
        """
        Return the effective question schema for a CaseDetail.
        Uses the individual CaseDetail schema if set, otherwise falls back to the collection template.
        """
        if casedetail.question_schema:
            return casedetail.question_schema
        return self.case_question_template


    def get_index_of_case(self, case, case_count=False):
        cases = list(self.get_cases())

        if case_count:
            return cases.index(case), len(cases)
        else:
            return cases.index(case)

    def get_case_by_index(
        self, case_index: int, case_count=False
    ) -> Case | Tuple[Case, int]:
        # Seems to be a bug when case_number is 0 or 1 the same (first) object gets returned
        # forces a list fixes (but is likely inefficient)
        cases = list(self.get_cases().prefetch_related())

        try:
            case = cases[case_index]
        except IndexError:  # Catch an invalid case_number
            s = f"Invalid case number: {case_index}"
            raise Http404(s)

        if case_count:
            return case, len(cases)
        else:
            return case

    def get_case_take_url(self, case):
        cases = list(self.get_cases().prefetch_related())

        return reverse(
            "atlas:collection_case_view_take_user",
            kwargs={"pk": self.pk, "case_number": cases.index(case)},
        )

    def check_user_can_take(self, cid, passcode, user=None, active_only=True):
        """
        Extend base check_user_can_take to also require completion of any
        prerequisite collections.
        """
        logger.error("Checking if user can take collection with prerequisites")
        logger.error(f"CID: {cid}, user: {user}, active_only: {active_only}")
        #` Perform the normal access checks first
        super().check_user_can_take(cid, passcode, user=user, active_only=active_only)

        # If there are prerequisites, the user (or CID) must have completed them
        if not self.prerequisites.exists():
            return

        for prereq in self.prerequisites.all():
            # Look up any existing exam record for this user/cid on the prerequisite
            ct = ContentType.objects.get_for_model(prereq)
            exam_record = None
            if cid is not None:
                # Find CidUser by cid
                try:
                    cid_user = CidUser.objects.filter(cid=cid).first()
                except Exception:
                    cid_user = None

                if cid_user is None:
                    exam_record = None
                else:
                    exam_record = CidUserExam.objects.filter(
                        content_type=ct, object_id=prereq.pk, cid_user=cid_user
                    ).first()
            else:
                # Check for a normal user_user exam record
                exam_record = CidUserExam.objects.filter(
                    content_type=ct, object_id=prereq.pk, user_user=user
                ).first()

            if exam_record is None or not getattr(exam_record, "completed", False):
                # Not allowed to take this collection until prereq completed
                # Raise the PrerequisiteRequired exception including the prereq object
                raise PrerequisiteRequired(
                    f"Collection not available until prerequisite '{prereq.name}' is completed.",
                    prereq=prereq,
                )

    def get_ohif_dicom_json(self, case_title_as_patient_name=True):
        studies = []
        for n, case in enumerate(self.cases.all()):
            series_json = []
            for series in case.series.all():
                series_json.append(series.get_series_dicom_json())

            patient_name = ""
            if case_title_as_patient_name:
                patient_name = case.title

            studies.append(
                {
                    "StudyInstanceUID": f"1.3.6.1.4.1.14519.5.2.1.6279.6001.{n}",
                    "StudyDate": "20000101",
                    "StudyTime": "",
                    "PatientName": patient_name,
                    "PatientID": f"ID{n}",
                    "AccessionNumber": "",
                    "PatientAge": "",
                    "PatientSex": "",
                    "series": series_json,
                }
            )

        return {"studies": studies}

    def add_case(self, case):
        """Adds a case to the collection and makes sure order is maintained"""
        # We might be better off adding via the case detail model direct
        # CaseDetail.objects.create(case=case, collection=self)
        self.cases.add(case)
        self.order_cases()

    def order_cases(self):
        """Modifies the casedetail sort_order to sequentially order the cases"""
        for n, c in enumerate(self.casedetail_set.all().order_by("sort_order")):
            c.sort_order = n
            c.save()


class SeriesDetail(models.Model):
    case = models.ForeignKey(Case, on_delete=models.CASCADE)
    series = models.ForeignKey(Series, on_delete=models.CASCADE)

    sort_order = models.IntegerField(default=1000)

    feedback = models.BooleanField(
        default=False,
        help_text="Set to true if the series should only be shown for feedback purposes.",
    )

    class Meta:
        ordering = ("sort_order",)


@receiver(post_save, sender=SeriesDetail)
def _populate_case_study_date_on_series_link(sender, instance, created, **kwargs):
    case = instance.case
    if case.study_date:
        return

    derived_study_date = case._get_series_study_date()
    if derived_study_date:
        Case.objects.filter(pk=case.pk, study_date__isnull=True).update(study_date=derived_study_date)

class CaseDisplaySet(models.Model, AuthorMixin):
    """
    This is analogous to a SeriesFinding but for a Case (it has
    access to all the series stacks).

    """
    case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="display_sets")

    name = models.CharField(
        max_length=255,
        help_text="Name of the display set",
    )

    description = models.TextField(
        blank=True,
        help_text="Description of the display set",
    )

    viewerstate = models.JSONField(
        null=True,
        blank=True,
        help_text="Viewer state for the display set",
    )

    annotations = models.JSONField(
        null=True,
        blank=True,
        help_text="Annotations for the display set",
    )

    findings = models.ManyToManyField(Finding, blank=True)
    structures = models.ManyToManyField(Structure, blank=True)
    conditions = models.ManyToManyField(Condition, blank=True)
   
    def viewerstate_string(self):
        return json.dumps(self.viewerstate) if self.viewerstate else "{}"

    def annotations_string(self):
        return json.dumps(self.annotations) if self.annotations else "{}"


class CaseDetail(models.Model):
    """A through table that stores the relationship between a case and a collection.

    This is what user answers are linked to (as there be different questions for the same case in a different collection)

    The collection questions and answers are also defined here.
    """
    case = models.ForeignKey(Case, on_delete=models.CASCADE)
    collection = models.ForeignKey(CaseCollection, on_delete=models.CASCADE)

    question_schema = models.JSONField(null=True, blank=True)
    question_answers = models.JSONField(null=True, blank=True)
    # TODO add feedback for questions
    #question_feedback = models.JSONField(null=True, blank=True)

    default_viewerstate = models.JSONField(
        null=True,
        blank=True,
        help_text="Default viewer state for the case"
    )

    sort_order = models.IntegerField(default=1000)

    redact_history = models.BooleanField(
        default=False,
        help_text="Set to true if the history should be redacted whilst taking the case."
    )

    override_history = models.TextField(
        null=True, blank=True,
        help_text="This will override the case history for the purpose of the exam/collection."
    )

    question_time_limit_override = models.PositiveIntegerField(
        null=True,
        blank=True,
        help_text="Optional per-case override for the answer time limit in seconds.",
    )

    answer_entry_grace_period_override = models.PositiveIntegerField(
        null=True,
        blank=True,
        help_text="Optional per-case override for additional answer-only time in seconds after case view lock.",
    )

    class SeriesVisibility(models.TextChoices):
        ALWAYS = "AL", _("Show all the time")
        REVIEW = "RE", _("Show on review")
        HIDDEN = "NO", _("Do not show")

    series_visibility_config = models.JSONField(
        default=dict,
        blank=True,
        help_text="Per-series visibility map for this case in this collection.",
    )

    learner_comment = models.TextField(
        blank=True,
        default="",
        help_text="Comment shown to the learner while taking the case and on review.",
    )

    learner_review_comment = models.TextField(
        blank=True,
        default="",
        help_text="Comment shown to the learner only on review.",
    )

    class Meta:
        ordering = ("sort_order",)

    def __str__(self) -> str:
        return f"{self.case} -> {self.collection}"


    def get_case_index(self):
        """Returns the question index (number) in the collection (0 indexed)"""
        case_details = list(self.collection.casedetail_set.all().order_by("sort_order"))

        return case_details.index(self)

    def get_question_schema(self):
        return json.dumps(self.question_schema)

    def get_question_answers(self):
        """Returns the correct question answers as a json string"""
        return json.dumps(self.question_answers)

    def get_user_answers(self, user):
        """Returns the users answers as a json string"""
        try:
            return UserReportAnswer.objects.get(question=self, user=user)
        except UserReportAnswer.DoesNotExist:
            return None

    def get_cid_answers(self, cid):
        """Returns the cid users answers as a json string"""
        try:
            return CidReportAnswer.objects.get(question=self, cid=cid)
        except CidReportAnswer.DoesNotExist:
            return None

    def default_viewerstate_string(self):
        return json.dumps(self.default_viewerstate) if self.default_viewerstate else "{}"

    def get_history_pre(self):
        if self.collection.show_history_pre:
            if self.redact_history:
                return "[Redacted]"
            if self.override_history and self.override_history != "":
                return self.override_history
            return self.case.history or "No history provided"
        return ""

    def get_history(self):
        if self.redact_history:
            return "[Redacted]"
        if self.override_history and self.override_history != "":
            return self.override_history
        return self.case.history or "No history provided"

    def get_series_visibility_map(self) -> dict[str, str]:
        raw_map = self.series_visibility_config or {}
        if not isinstance(raw_map, dict):
            return {}

        valid_values = {
            self.SeriesVisibility.ALWAYS,
            self.SeriesVisibility.REVIEW,
            self.SeriesVisibility.HIDDEN,
        }
        cleaned = {}
        for key, value in raw_map.items():
            key_str = str(key)
            value_str = str(value)
            if value_str in valid_values:
                cleaned[key_str] = value_str
        return cleaned

    def get_series_visibility_for(self, series, review_mode: bool = False) -> str:
        visibility = self.get_series_visibility_map().get(
            str(series.pk), self.SeriesVisibility.ALWAYS
        )
        if visibility == self.SeriesVisibility.REVIEW and not review_mode:
            return self.SeriesVisibility.HIDDEN
        return visibility

    def get_visible_case_series(self, review_mode: bool = False):
        visible = []
        for series in self.case.get_ordered_series():
            visibility = self.get_series_visibility_for(series, review_mode=review_mode)
            if visibility != self.SeriesVisibility.HIDDEN:
                visible.append(series)
        return visible

    def get_case_series_nested(self, include_priors=True, review_mode: bool = False):
        case_series_images = []
        for series in self.get_visible_case_series(review_mode=review_mode):
            case_series_images.append([
                image.image.url
                for image in series.images.filter(removed=False)
                if image.image
            ])

        if include_priors:
            logger.debug(f"Checking for prior cases for case {self.case}")
            logger.debug(f"Found {self.case.prior_case.count()} prior cases for case {self.case}")

            for prior in self.caseprior_set.all():
                if prior.prior_visibility == CasePrior.PriorVisibility.NONE:
                    continue
                if prior.prior_visibility == CasePrior.PriorVisibility.REVIEW and not review_mode:
                    continue
                logger.debug(f"Adding prior case {prior.prior_case} to case {self.case}")
                case_series_images.extend(prior.prior_case.get_series_images_nested(as_json=False))

        return json.dumps(case_series_images)

    def get_case_named_stacks(self, include_priors=True, review_mode: bool = False):
        def build_stacks_for(case_obj, prefix=None):
            stacks = []
            if case_obj == self.case:
                series_iterable = self.get_visible_case_series(review_mode=review_mode)
            else:
                series_iterable = case_obj.get_ordered_series()

            for series in series_iterable:
                series_images = list(series.get_images())
                series_images_with_urls = [(img, f"{REMOTE_URL}{img.image.url}") for img in series_images]
                images = [url for _, url in series_images_with_urls]
                declared_groups = _build_declared_series_groups(series, series_images_with_urls)
                name = f"{prefix}: {series}" if prefix else str(series)
                stacks.append(
                    {
                        "name": name,
                        "imageIds": images,
                        "seriesId": series.pk,
                        "series": declared_groups,
                    }
                )
            return stacks

        results = []

        # main case entry
        results.append(
            {
                "caseId": f"CASE-{self.case.pk}",
                "studyId": f"Current Case",
                "stacks": build_stacks_for(self.case, prefix=None),
            }
        )

        # include priors as separate entries
        if include_priors:
            for prior in self.caseprior_set.all():
                if prior.prior_visibility == CasePrior.PriorVisibility.NONE:
                    continue
                if prior.prior_visibility == CasePrior.PriorVisibility.REVIEW and not review_mode:
                    continue
                prior_case = prior.prior_case
                label = "Prior" if prior.relation_type == CasePrior.RelationType.PRIOR else "Future"
                results.append(
                    {
                        "caseId": f"CASE-{prior_case.pk}",
                        "studyId": f"{label}: {prior.relation_text}",
                        "stacks": build_stacks_for(prior_case, prefix=label),
                    }
                )

        return json.dumps(results)

    def render_example_form(self, request=None):
        """Build and return a rendered HTML snippet for the example answers form.

        If `request` is provided it will be passed to the template renderer so
        csrf tokens and other context processors work correctly. The returned
        HTML contains the json-editor widget (the `json_answer` field) and a
        submit button for saving example answers.
        """
        from django.template.loader import render_to_string
        from django.utils.safestring import mark_safe
        from .forms import JsonAnswerForm

        post_data = {}
        # Ensure we pass the stored answers into the form so the widget is populated
        post_data["json_answer"] = json.dumps(self.question_answers) if self.question_answers is not None else json.dumps({})

        form = JsonAnswerForm(post_data, question_schema=self.question_schema)

        html = render_to_string("atlas/_rendered_example_form.html", {"form": form}, request=request)
        return mark_safe(html)

class CasePrior(models.Model):
    casedetail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
    prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case")

    relation_text = models.CharField(max_length=255, blank=True, help_text="Text to describe the relationship between the cases")

    class RelationType(models.TextChoices):
        PRIOR = "PR", _("Prior")
        FUTURE = "FU", _("Future")

    relation_type = models.CharField(
        max_length=2,
        choices=RelationType.choices,
        default=RelationType.PRIOR,
        help_text="Defines whether the related case is a prior or a future exam",
    )

    class PriorVisibility(models.TextChoices):
        NONE = "NO", _("None")
        ALWAYS = "AL", _("Always")
        REVIEW = "RE", _("Review")


    prior_visibility = models.CharField(
        max_length=2,
        choices=PriorVisibility.choices,
        default=PriorVisibility.ALWAYS,
        help_text="Defines when the prior case is shown to the user",
    )

    class Meta:
        unique_together = ("casedetail", "prior_case")

    def __str__(self) -> str:
        return f"{self.casedetail.case} -> {self.prior_case}"

class BaseReportAnswer(models.Model):
    question = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)

    answer = models.TextField(blank=True)

    json_answer = models.JSONField(null=True, blank=True)

    feedback = models.TextField(blank=True)

    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    score = models.IntegerField(
        blank=True, null=True, validators=[MaxValueValidator(10), MinValueValidator(0)]
    )

    completed = models.BooleanField(default=False)
    # Timestamp when the user first loaded the question (started answering)
    started_at = models.DateTimeField(null=True, blank=True)
    # Timestamp when the answer was submitted/saved
    submitted_at = models.DateTimeField(null=True, blank=True)

    def save(self, *args, **kwargs):
        self.clean()
        return super(BaseReportAnswer, self).save(*args, **kwargs)

    def clean(self):
        if self.answer:
            self.answer = self.answer.strip()

    def get_answer_score(self):
        return self.score

    def set_cid_or_user(self, cid=None, user=None):
        if cid is not None:
            self.cid = cid
        elif user is not None:
            self.user = user
        else:
            raise ValueError("No cid or user specified")

    def get_correct_json_answers(self):
        logger.debug(f"Getting correct json answers for question {self.question.pk}")
        if self.question.question_schema is None or not "properties" in self.question.question_schema:
            return []
        answers = []
        for name, value in self.question.question_schema["properties"].items():
            logger.debug(f"Processing question property '{name}' with schema {value}")

            # Safely retrieve the user's answer and the stored correct answer.
            # json_answer or question_answers may be None (or not a dict) if not set,
            # so check before subscripting to avoid TypeError.
            if isinstance(self.json_answer, dict):
                user_answer = self.json_answer.get(name, "")
            else:
                user_answer = ""

            qa = getattr(self.question, "question_answers", None)
            if isinstance(qa, dict):
                correct_answer = qa.get(name, "")
            else:
                correct_answer = ""

            match value:
                case {"type": "string", "enum": _}:
                    automark = True
                    answer_is_correct = user_answer == correct_answer
                case {"type": "string"}:
                    if user_answer == correct_answer:
                        automark = True
                        answer_is_correct = True
                    else:
                        automark = False
                        answer_is_correct = False
                case _:
                    automark = False
                    answer_is_correct = user_answer == correct_answer
            answers.append((value, user_answer, correct_answer, answer_is_correct, automark))

        return answers

    #def get_marked_answers_html(self):
    #    """Returns an HTML representation of the users answers with correct answers highlighted"""
    #    html = "<div class='json-answers'>"
    #    for value, user_answer, correct_answer, answer_is_correct, automark in self.get_correct_json_answers():
    #        if automark:
    #            if answer_is_correct:
    #                html += f"<div class='answer correct'>{user_answer}</div>"
    #            else:
    #                html += f"<div class='answer incorrect'>Your answer: {user_answer}<br/>Correct answer: {correct_answer}</div>"
    #        else:
    #            html += f"<div class='answer unmarked'>Your answer: {user_answer} (Not auto-marked)</div>"

    #    html += "</div>"

    #    return format_html(html)

    class Meta:
        abstract = True


@reversion.register
class CidReportAnswer(BaseReportAnswer):
    cid = models.BigIntegerField(
        blank=False,
        help_text="Candidate ID",
    )


@reversion.register
class UserReportAnswer(BaseReportAnswer):
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)


class SelfReview(models.Model):
    """Holds data regarding a users self review / reflection"""

    user_exam = models.ForeignKey(CidUserExam, on_delete=models.CASCADE)

    case = models.ForeignKey(Case, on_delete=models.CASCADE)

    comments = models.TextField(null=True, blank=True)

    findings = models.IntegerField(
        null=True, blank=True, validators=[MaxValueValidator(5), MinValueValidator(1)]
    )
    interpretation = models.IntegerField(
        null=True, blank=True, validators=[MaxValueValidator(5), MinValueValidator(1)]
    )

    review_date = models.DateTimeField(auto_now_add=True)
    review_update_date = models.DateTimeField(auto_now=True)

    def __str__(self) -> str:
        date = self.review_date

        if self.review_update_date is not None:
            date = self.review_update_date

        return f"Self review: {date:%Y-%m-%d} / {self.user_exam.exam} - {self.case}"

    def get_absolute_url(self):
        return self.user_exam.exam.get_case_take_url(self.case)

    def get_display_block(self):
        html = """<div class='self-feedback-block'>
        Comments: <span class="comment">{comments}</span><br/>

        Findings: <span class="findings">{findings}</span><br/>

        Interpretation: <span class="interpretation">{interpretation}</span><br/>
        <span class="date">Date: {date}</span><br/><a href="{edit_url}">Edit</a>
        </div>
        
        """

        edit_url = reverse(
            "atlas:self_review_update",
            kwargs={
                "user_exam_id": self.user_exam.pk,
                "case_id": self.case.pk,
                "pk": self.pk,
            },
        )
        # edit_url = "TEST"

        return format_html(
            html,
            comments=self.comments,
            findings=self.findings,
            interpretation=self.interpretation,
            date=self.review_date,
            id=self.pk,
            edit_url=edit_url,
        )


class CaseReviewMessage(models.Model):
    """Conversation message for one learner attempt on one case.

    Supervisors/collection authors use this for per-case feedback and replies.
    Learners use it to raise questions/queries. Supervisor messages may require
    acknowledgement so they continue to flag on the learner-facing pages until
    explicitly acknowledged.
    """

    class MessageType(models.TextChoices):
        FEEDBACK = "feedback", _("Feedback")
        QUERY = "query", _("Query")
        REPLY = "reply", _("Reply")

    cid_user_exam = models.ForeignKey(
        CidUserExam,
        on_delete=models.CASCADE,
        related_name="case_review_messages",
    )
    case = models.ForeignKey(
        Case,
        on_delete=models.CASCADE,
        related_name="case_review_messages",
    )
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="case_review_messages_authored",
        null=True,
        blank=True,
    )
    author_label = models.CharField(max_length=255, blank=True)
    message_type = models.CharField(
        max_length=20,
        choices=MessageType.choices,
        default=MessageType.QUERY,
    )
    message = models.TextField()
    requires_acknowledgement = models.BooleanField(default=False)
    acknowledged_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ("created_at",)

    def __str__(self) -> str:
        return f"{self.get_message_type_display()} for {self.case}"

    @property
    def is_acknowledged(self) -> bool:
        return self.acknowledged_at is not None

    def get_author_display(self) -> str:
        if self.author is not None:
            full_name = self.author.get_full_name().strip()
            return full_name or self.author.username
        return self.author_label or "Learner"

    def acknowledge(self):
        if self.acknowledged_at is None:
            self.acknowledged_at = timezone.now()
            self.save(update_fields=["acknowledged_at"])


class UncategorisedDicom(models.Model):
    # We use image to maintain consitency across apps
    image = models.FileField(upload_to=uncategorised_dicom_directory_path)

    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        help_text="The user that uploaded the file",
    )

    series = models.ForeignKey(
        "Series",
        related_name="imported_dicoms",
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
    )

    series_instance_uid = models.CharField(max_length=255, blank=True, null=True)

    image_blake3_hash = models.CharField(max_length=64, null=True, blank=True, db_index=True)

    created_date = models.DateTimeField(default=timezone.now)

    basic_dicom_tags = models.JSONField(null=True, blank=True)

    def check_for_duplicates(self, image_hash=None|str):
        duplicate = None

        if image_hash is None:
            image_hash = self.image_blake3_hash

        if obj := UncategorisedDicom.objects.filter(
            image_blake3_hash=image_hash
        ).first():
            # duplicate = obj
            return obj

        if obj := SeriesImage.objects.filter(image_blake3_hash=image_hash).first():
            duplicate = obj

        return duplicate

    def save(self, *args, **kwargs):
        """Override save method to add image hash"""
        if self.image:
            ds = self.get_dicom_info()

            try:
                self.series_instance_uid = ds["SeriesInstanceUID"].value
            except AttributeError:
                raise InvalidDicomError("Invalid dicom file")

            # if self.check_for_duplicates():
            #    return DuplicateDicom
            image_blake3_hash = get_image_dicom_hash(
                None, dataset=ds, hash_type="blake3", direct_pixel_data=True
            )

            self.image_blake3_hash = image_blake3_hash

            duplicate = self.check_for_duplicates(image_blake3_hash)

            self.basic_dicom_tags = self.get_basic_dicom_tags(dataset=ds)

            if duplicate is not None:
                if duplicate != self:
                    raise DuplicateDicom(duplicate)


        # Hack for tests
        if image_blake3_hash != "1234":
            super().save(*args, **kwargs)  # Call the "real" save() method.

    def get_dicom_info(self):
        try:
            ds = pydicom.dcmread(self.image)
            return ds
        except pydicom.errors.InvalidDicomError:
            return {"SeriesInstanceUID": "1234"}

    def get_basic_dicom_tags(self, dataset=None):
        try:
            if dataset is None:
                dataset = pydicom.dcmread(self.image)
            to_include = (
                "StudyInstanceUID",
                "StudyDescription",
                "Modality",
                "ProtocolName",
                "SeriesDescription",
                "SeriesInstanceUID",
                "BodyPartExamined",
                "SliceThickness",
                "SeriesNumber",
                "StudyDate",
                "PatientID",
            )

            tags = {}

            for tag in to_include:
                if tag in dataset:
                    tags[tag] = dataset[tag].value
                else:
                    tags[tag] = ""

            return tags
        except pydicom.errors.InvalidDicomError:
            return None

    def get_series(self):
        return self.series

    def get_dicom_url(self):
        return f"{REMOTE_URL}{self.image.url}"




class DuplicateDicom(Exception):
    def __init__(self, duplicate):
        self.duplicate = duplicate


class Resource(models.Model, AuthorMixin):
    name = models.CharField(max_length=255)
    description = models.TextField(blank=True, null=True)
    url = models.URLField(blank=True, null=True)
    file = models.FileField(upload_to="atlas/resources/", blank=True, null=True)
    author = models.ManyToManyField(
        settings.AUTH_USER_MODEL,
        blank=True,
        help_text="Author of the resource",
        related_name="resources",
    )
    created_date = models.DateTimeField(default=timezone.now)
    modified_date = models.DateTimeField(auto_now=True)
    # Link resources to one or more Sites (from generic.Site)
    sites = models.ManyToManyField(
        'generic.Site',
        blank=True,
        help_text='The site(s) this resource is associated with (if any).',
        related_name='resources',
    )

    # Categorise resources by subspecialty
    subspecialty = models.ManyToManyField(
        Subspecialty,
        blank=True,
        help_text='Subspecialty or subspecialties this resource relates to.',
    )

    open_access = models.BooleanField(
        default=True, 
        help_text="If true the resource is available to all users, otherwise only in the context of cases."
    )

    def __str__(self) -> str:
        return self.name

    def get_absolute_url(self):
        return reverse("atlas:resource_detail", kwargs={"pk": self.pk})

    def get_link(self):
        return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)

    def get_display(self) -> str:
        if self.url:
            html =  f"<a target='_blank' href='{self.url}'>{self.name}</a>"
        elif self.file:
            filetype, _ = mimetypes.guess_type(self.file.url)

            match filetype.split("/"):
                case "image", _:
                    html = f"<img src='{self.file.url}'>"
                case "video", _:
                    html = f"<video controls><source src='{self.file.url}'></video>"

                case _:
                    html =  f"<a target='_blank' href='{self.file.url}'>{self.name}</a>"
        else:
            html =  self.name

        meta = ''
        if self.sites.exists():
            meta += ' Sites: ' + ', '.join([s.short_code or s.full_name for s in self.sites.all()])
        if self.subspecialty.exists():
            meta += ' Subspecialty: ' + ', '.join([str(s) for s in self.subspecialty.all()])

        return format_html(
            "<details class='resource-block'><summary>{}</summary><div>{}</div><div class='text-muted small'>{}</div></details>",
            self.name,
            mark_safe(html),
            meta,
        )



class CaseResource(models.Model):
    resource = models.ForeignKey(Resource, on_delete=models.CASCADE)
    case = models.ForeignKey(Case, on_delete=models.CASCADE)

    pre_review = models.BooleanField(
        default=False,
        help_text="Defines if the resource should be shown pre review (i.e. for pre reading before the case is taken)",
    )

    def __str__(self) -> str:
        return f"{self.resource} - {self.case} (Pre: {self.pre_review})"


class QuestionSchema(models.Model, AuthorMixin):
    name = models.CharField(max_length=255)
    description = models.TextField(blank=True, null=True)
    schema = models.JSONField()

    def get_absolute_url(self):
        return reverse("atlas:question_schema_detail", kwargs={"pk": self.pk})
    
    def get_example_form(self):
        from .forms import JsonAnswerForm
        example_form = JsonAnswerForm(
            question_schema=self.schema
        )
        return example_form

    def get_schema_as_json(self) -> str:
        return json.dumps(self.schema)

    def __str__(self) -> str:
        return "{}".format(self.name)


class PrerequisiteRequired(Exception):
    """Raised when a user attempts to access a collection but has not completed a prerequisite.

    The exception stores an optional `prereq` attribute pointing to the prerequisite
    CaseCollection instance so views can render a helpful page linking to it.
    """

    def __init__(self, message=None, *, prereq=None):
        super().__init__(message or "Prerequisite required")
        self.prereq = prereq


