"""
camcops_server/tasks/ace3.py

===============================================================================

    Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
    Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

    This file is part of CamCOPS.

    CamCOPS is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    CamCOPS is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.

===============================================================================

ACE-III and Mini-ACE.

"""

from typing import Any, cast, Iterable, List, Optional, Type

from cardinal_pythonlib.stringfunc import strseq
import cardinal_pythonlib.rnc_web as ws
import numpy
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql.sqltypes import Integer, String, UnicodeText

from camcops_server.cc_modules.cc_blob import (
    Blob,
    blob_relationship,
    get_blob_img_html,
)
from camcops_server.cc_modules.cc_constants import CssClass, PlotDefaults, PV
from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
from camcops_server.cc_modules.cc_db import add_multiple_columns
from camcops_server.cc_modules.cc_html import (
    answer,
    get_yes_no_none,
    italic,
    pmid,
    subheading_spanning_two_columns,
    tr,
    tr_qa,
    tr_span_col,
)
from camcops_server.cc_modules.cc_request import CamcopsRequest
from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup
from camcops_server.cc_modules.cc_sqla_coltypes import (
    BIT_CHECKER,
    mapped_camcops_column,
    PermittedValueChecker,
)
from camcops_server.cc_modules.cc_summaryelement import SummaryElement
from camcops_server.cc_modules.cc_task import (
    Task,
    TaskHasClinicianMixin,
    TaskHasPatientMixin,
)
from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo


# =============================================================================
# Constants
# =============================================================================

ADDRESS_PARTS = [
    "forename",
    "surname",
    "number",
    "street_1",
    "street_2",
    "town",
    "county",
]
RECALL_WORDS = ["lemon", "key", "ball"]
PERCENT_DP = 1

TOTAL_MAX = 100
ATTN_MAX = 18
MEMORY_MAX = 26
FLUENCY_MAX = 14
LANG_MAX = 26
VSP_MAX = 16

ATTN_MINIACE_MAX = 4
MEM_MINIACE_MAX = 14
FLUENCY_MINIACE_MAX = 7
VSP_MINIACE_MAX = 5
MINI_ACE_MAX = 30

AGE_FTE = "Age on leaving full-time education"
OCCUPATION = "Occupation"
HANDEDNESS = "Handedness"
N_ATTN_TIME_ACE = 5
N_ATTN_TIME_MINIACE = 4
N_MEM_REPEAT_RECALL_ADDR = 7
ANIMAL_FLUENCY_SCORING_HTML = (
    "Score for animals <i>(≥22 scores 7, 17–21 scores 6, 14–16 scores 5, "
    "11–13 scores 4, 9–10 scores 3, 7–8 scores 2, 5–6 scores 1, "
    "&lt;5 scores 0)</i>"
)
ACE3_COPYRIGHT = """
ACE-III: Copyright © 2012, John Hodges. “The ACE-III is available for free. The
copyright is held by Professor John Hodges who is happy for the test to be used
in clinical practice and research projects. There is no need to contact us if
you wish to use the ACE-III in clinical practice.” (ACE-III FAQ, 7 July 2013,
www.neura.edu.au).
"""
MINI_ACE_THRESHOLDS = f"""
In the mini-ACE, scores ≤21 had sensitivity 0.61 and specificity 1.0 for
dementia, and scores ≤25 had sensitivity 0.85 and specificity 0.87 for
dementia, in a context of patients with Alzheimer’s disease, behavioural
variant frontotemporal dementia, corticobasal syndrome, primary progressive
aphasia, and controls (Hsieh et al. 2015, {pmid(25227877)}.
"""


# =============================================================================
# Ancillary functions
# =============================================================================


def score_zero_for_absent(x: Optional[int]) -> int:
    """0 if x is None else x"""
    return 0 if x is None else x


def percent(score: int, maximum: int) -> str:
    return ws.number_to_dp(100 * score / maximum, PERCENT_DP)


def tr_score_with_pct(title: str, score: int, maximum: int) -> str:
    return tr(
        title,
        answer(score) + f" / {maximum} ({percent(score, maximum)}%)",
    )


def qsequence(target_addr_parts: Iterable[str]) -> str:
    """
    For e.g. "Harry? Barnes? ..."
    """
    return " ".join(f"{x}?" for x in target_addr_parts)


def tr_heading(left: str, right: str) -> str:
    """
    HTML for header row of most tables.
    """
    return f"""
        <tr>
            <th width="67%">{left}</th>
            <th width="33%">{right}</th>
        </tr>
    """


# =============================================================================
# ACE-III
# =============================================================================


class Ace3(TaskHasPatientMixin, TaskHasClinicianMixin, Task):  # type: ignore[misc]  # noqa: E501
    """
    Server implementation of the ACE-III task.
    """

    __tablename__ = "ace3"
    shortname = "ACE-III"
    provides_trackers = True

    prohibits_commercial = True

    @classmethod
    def extend_columns(cls: Type["Ace3"], **kwargs: Any) -> None:
        add_multiple_columns(
            cls,
            "attn_time",
            1,
            5,
            pv=PV.BIT,
            comment_fmt="Attention, time, {n}/5, {s} (0 or 1)",
            comment_strings=["day", "date", "month", "year", "season"],
        )
        add_multiple_columns(
            cls,
            "attn_place",
            1,
            5,
            pv=PV.BIT,
            comment_fmt="Attention, place, {n}/5, {s} (0 or 1)",
            comment_strings=[
                "house number/floor",
                "street/hospital",
                "town",
                "county",
                "country",
            ],
        )
        add_multiple_columns(
            cls,
            "attn_repeat_word",
            1,
            3,
            pv=PV.BIT,
            comment_fmt="Attention, repeat word, {n}/3, {s} (0 or 1)",
            comment_strings=RECALL_WORDS,
        )
        add_multiple_columns(
            cls,
            "attn_serial7_subtraction",
            1,
            5,
            pv=PV.BIT,
            comment_fmt="Attention, serial sevens, {n}/5 (0 or 1)",
        )

        add_multiple_columns(
            cls,
            "mem_recall_word",
            1,
            3,
            pv=PV.BIT,
            comment_fmt="Memory, recall word, {n}/3, {s} (0 or 1)",
            comment_strings=RECALL_WORDS,
        )
        add_multiple_columns(
            cls,
            "mem_repeat_address_trial1_",
            1,
            N_MEM_REPEAT_RECALL_ADDR,
            pv=PV.BIT,
            comment_fmt="Memory, address registration trial 1/3 "
            "(not scored), {s} (0 or 1)",
            comment_strings=ADDRESS_PARTS,
        )
        add_multiple_columns(
            cls,
            "mem_repeat_address_trial2_",
            1,
            N_MEM_REPEAT_RECALL_ADDR,
            pv=PV.BIT,
            comment_fmt="Memory, address registration trial 2/3 "
            "(not scored), {s} (0 or 1)",
            comment_strings=ADDRESS_PARTS,
        )
        add_multiple_columns(
            cls,
            "mem_repeat_address_trial3_",
            1,
            N_MEM_REPEAT_RECALL_ADDR,
            pv=PV.BIT,
            comment_fmt="Memory, address registration trial 3/3 "
            "(scored), {s} (0 or 1)",
            comment_strings=ADDRESS_PARTS,
        )
        add_multiple_columns(
            cls,
            "mem_famous",
            1,
            4,
            pv=PV.BIT,
            comment_fmt="Memory, famous people, {n}/4, {s} (0 or 1)",
            comment_strings=["current PM", "woman PM", "USA president", "JFK"],
        )

        add_multiple_columns(
            cls,
            "lang_follow_command",
            1,
            3,
            pv=PV.BIT,
            comment_fmt="Language, command {n}/3 (0 or 1)",
        )
        add_multiple_columns(
            cls,
            "lang_write_sentences_point",
            1,
            2,
            pv=PV.BIT,
            comment_fmt="Language, write sentences, {n}/2, {s} (0 or 1)",
            comment_strings=[
                "two sentences on same topic",
                "grammar/spelling",
            ],
        )
        add_multiple_columns(
            cls,
            "lang_repeat_word",
            1,
            4,
            pv=PV.BIT,
            comment_fmt="Language, repeat word, {n}/4, {s} (0 or 1)",
            comment_strings=[
                "caterpillar",
                "eccentricity",
                "unintelligible",
                "statistician",
            ],
        )
        add_multiple_columns(
            cls,
            "lang_repeat_sentence",
            1,
            2,
            pv=PV.BIT,
            comment_fmt="Language, repeat sentence, {n}/2, {s} (0 or 1)",
            comment_strings=["glitters_gold", "stitch_time"],
        )
        add_multiple_columns(
            cls,
            "lang_name_picture",
            1,
            12,
            pv=PV.BIT,
            comment_fmt="Language, name picture, {n}/12, {s} (0 or 1)",
            comment_strings=[
                "spoon",
                "book",
                "kangaroo/wallaby",
                "penguin",
                "anchor",
                "camel/dromedary",
                "harp",
                "rhinoceros",
                "barrel/keg/tub",
                "crown",
                "alligator/crocodile",
                "accordion/piano accordion/squeeze box",
            ],
        )
        add_multiple_columns(
            cls,
            "lang_identify_concept",
            1,
            4,
            pv=PV.BIT,
            comment_fmt="Language, identify concept, {n}/4, {s} (0 or 1)",
            comment_strings=["monarchy", "marsupial", "Antarctic", "nautical"],
        )

        add_multiple_columns(
            cls,
            "vsp_count_dots",
            1,
            4,
            pv=PV.BIT,
            comment_fmt="Visuospatial, count dots {n}/4, {s} dots (0-1)",
            comment_strings=["8", "10", "7", "9"],
        )
        add_multiple_columns(
            cls,
            "vsp_identify_letter",
            1,
            4,
            pv=PV.BIT,
            comment_fmt="Visuospatial, identify letter {n}/4, {s} (0-1)",
            comment_strings=["K", "M", "A", "T"],
        )
        add_multiple_columns(
            cls,
            "mem_recall_address",
            1,
            N_MEM_REPEAT_RECALL_ADDR,
            pv=PV.BIT,
            comment_fmt="Memory, recall address {n}/7, {s} (0-1)",
            comment_strings=ADDRESS_PARTS,
        )
        add_multiple_columns(
            cls,
            "mem_recognize_address",
            1,
            5,
            pv=PV.BIT,
            comment_fmt="Memory, recognize address {n}/5 (if "
            "applicable) ({s}) (0-1)",
            comment_strings=["name", "number", "street", "town", "county"],
        )
        add_multiple_columns(  # tablet version 2.0.0 onwards
            cls,
            "mem_recognize_address_choice",
            1,
            5,
            coltype=String(length=1),  # was Text
            comment_fmt="Memory, recognize address {n}/5, CHOICE (if "
            "applicable) ({s}) (A/B/C)",
            comment_strings=["name", "number", "street", "town", "county"],
        )

    task_edition: Mapped[Optional[str]] = mapped_camcops_column(
        String(length=255),
        comment="Task edition. Older task instances will have NULL and that "
        "indicates UK English, 2012 version.",
    )
    task_address_version: Mapped[Optional[str]] = mapped_camcops_column(
        String(length=1),
        comment="Task version, determining the address for recall (A/B/C). "
        "Older task instances will have NULL and that indicates version A.",
        permitted_value_checker=PermittedValueChecker(
            permitted_values=["A", "B", "C"]
        ),
    )
    remote_administration: Mapped[Optional[bool]] = mapped_camcops_column(
        permitted_value_checker=BIT_CHECKER,
        comment="Task performed using remote (videoconferencing) "
        "administration?",
    )
    age_at_leaving_full_time_education: Mapped[Optional[int]] = mapped_column(
        comment="Age at leaving full time education",
    )
    occupation: Mapped[Optional[str]] = mapped_column(
        UnicodeText, comment="Occupation"
    )
    handedness: Mapped[Optional[str]] = mapped_camcops_column(
        String(length=1),  # was Text
        comment="Handedness (L or R)",
        permitted_value_checker=PermittedValueChecker(
            permitted_values=["L", "R"]
        ),
    )
    attn_num_registration_trials: Mapped[Optional[int]] = mapped_column(
        comment="Attention, repetition, number of trials (not scored)",
    )
    fluency_letters_score: Mapped[Optional[int]] = mapped_camcops_column(
        comment="Fluency, words beginning with P, score 0-7",
        permitted_value_checker=PermittedValueChecker(minimum=0, maximum=7),
    )
    fluency_animals_score: Mapped[Optional[int]] = mapped_camcops_column(
        comment="Fluency, animals, score 0-7",
        permitted_value_checker=PermittedValueChecker(minimum=0, maximum=7),
    )
    lang_follow_command_practice: Mapped[Optional[int]] = (
        mapped_camcops_column(
            comment="Language, command, practice trial (not scored)",
            permitted_value_checker=BIT_CHECKER,
        )
    )
    lang_read_words_aloud: Mapped[Optional[int]] = mapped_camcops_column(
        comment="Language, read five irregular words (0 or 1)",
        permitted_value_checker=BIT_CHECKER,
    )
    vsp_copy_infinity: Mapped[Optional[int]] = mapped_camcops_column(
        comment="Visuospatial, copy infinity (0-1)",
        permitted_value_checker=BIT_CHECKER,
    )
    vsp_copy_cube: Mapped[Optional[int]] = mapped_camcops_column(
        comment="Visuospatial, copy cube (0-2)",
        permitted_value_checker=PermittedValueChecker(minimum=0, maximum=2),
    )
    vsp_draw_clock: Mapped[Optional[int]] = mapped_camcops_column(
        comment="Visuospatial, draw clock (0-5)",
        permitted_value_checker=PermittedValueChecker(minimum=0, maximum=5),
    )
    picture1_blobid: Mapped[Optional[int]] = mapped_camcops_column(
        comment="Photo 1/2 PNG BLOB ID",
        is_blob_id_field=True,
        blob_relationship_attr_name="picture1",
    )
    picture2_blobid: Mapped[Optional[int]] = mapped_camcops_column(
        comment="Photo 2/2 PNG BLOB ID",
        is_blob_id_field=True,
        blob_relationship_attr_name="picture2",
    )
    comments: Mapped[Optional[str]] = mapped_column(
        UnicodeText, comment="Clinician's comments"
    )

    picture1: Mapped[Optional[Blob]] = blob_relationship(  # type: ignore[assignment]  # noqa: E501
        "Ace3", "picture1_blobid"
    )
    picture2: Mapped[Optional[Blob]] = blob_relationship(  # type: ignore[assignment]  # noqa: E501
        "Ace3", "picture2_blobid"
    )

    ATTN_SCORE_FIELDS = (
        strseq("attn_time", 1, N_ATTN_TIME_ACE)
        + strseq("attn_place", 1, 5)
        + strseq("attn_repeat_word", 1, 3)
        + strseq("attn_serial7_subtraction", 1, 5)
    )
    MEM_NON_RECOG_SCORE_FIELDS = (
        strseq("mem_recall_word", 1, 3)
        + strseq("mem_repeat_address_trial3_", 1, N_MEM_REPEAT_RECALL_ADDR)
        + strseq("mem_famous", 1, 4)
        + strseq("mem_recall_address", 1, N_MEM_REPEAT_RECALL_ADDR)
    )
    LANG_SIMPLE_SCORE_FIELDS = (
        strseq("lang_write_sentences_point", 1, 2)
        + strseq("lang_repeat_sentence", 1, 2)
        + strseq("lang_name_picture", 1, 12)
        + strseq("lang_identify_concept", 1, 4)
    )
    LANG_FOLLOW_CMD_FIELDS = strseq("lang_follow_command", 1, 3)
    LANG_REPEAT_WORD_FIELDS = strseq("lang_repeat_word", 1, 4)
    VSP_SIMPLE_SCORE_FIELDS = strseq("vsp_count_dots", 1, 4) + strseq(
        "vsp_identify_letter", 1, 4
    )
    BASIC_COMPLETENESS_FIELDS = (
        ATTN_SCORE_FIELDS
        + MEM_NON_RECOG_SCORE_FIELDS
        + ["fluency_letters_score", "fluency_animals_score"]
        + ["lang_follow_command_practice"]
        + LANG_SIMPLE_SCORE_FIELDS
        + LANG_REPEAT_WORD_FIELDS
        + [
            "lang_read_words_aloud",
            "vsp_copy_infinity",
            "vsp_copy_cube",
            "vsp_draw_clock",
        ]
        + VSP_SIMPLE_SCORE_FIELDS
        + strseq("mem_recall_address", 1, N_MEM_REPEAT_RECALL_ADDR)
    )
    MINI_ACE_FIELDS = (
        strseq("attn_time", 1, N_ATTN_TIME_MINIACE)  # 4 points; not season
        + ["fluency_animals_score"]  # 7 points
        + strseq("mem_repeat_address_trial3_", 1, N_MEM_REPEAT_RECALL_ADDR)
        # ... 7 points
        + ["vsp_draw_clock"]  # 5 points
        + strseq("mem_recall_address", 1, N_MEM_REPEAT_RECALL_ADDR)  # 7 points
    )

    @staticmethod
    def longname(req: "CamcopsRequest") -> str:
        _ = req.gettext
        return _("Addenbrooke’s Cognitive Examination III")

    def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
        return [
            TrackerInfo(
                value=self.total_score(),
                plot_label="ACE-III total score",
                axis_label=f"Total score (out of {TOTAL_MAX})",
                axis_min=-0.5,
                axis_max=TOTAL_MAX + 0.5,
                # Traditional cutoffs: ≤82, ≤88
                horizontal_lines=[82.5, 88.5],
            ),
            TrackerInfo(
                value=self.mini_ace_score(),
                plot_label="Mini-ACE score",
                axis_label=f"Mini-ACE score (out of {MINI_ACE_MAX})",
                axis_min=-0.5,
                axis_max=MINI_ACE_MAX + 0.5,
                # Traditional cutoffs: ≤21, ≤25
                horizontal_lines=[21.5, 25.5],
            ),
        ]

    def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
        if not self.is_complete():
            return CTV_INCOMPLETE
        a = self.attn_score()
        m = self.mem_score()
        f = self.fluency_score()
        lang = self.lang_score()
        v = self.vsp_score()
        t = a + m + f + lang + v
        mini = self.mini_ace_score()
        text = (
            f"ACE-III total: {t}/{TOTAL_MAX} "
            f"(attention {a}/{ATTN_MAX}, memory {m}/{MEMORY_MAX}, "
            f"fluency {f}/{FLUENCY_MAX}, language {lang}/{LANG_MAX}, "
            f"visuospatial {v}/{VSP_MAX}, "
            f"mini-ACE score {mini}/{MINI_ACE_MAX})"
        )
        return [CtvInfo(content=text)]

    def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
        return self.standard_task_summary_fields() + [
            SummaryElement(
                name="total",
                coltype=Integer(),
                value=self.total_score(),
                comment=f"Total score (/{TOTAL_MAX})",
            ),
            SummaryElement(
                name="attn",
                coltype=Integer(),
                value=self.attn_score(),
                comment=f"Attention (/{ATTN_MAX})",
            ),
            SummaryElement(
                name="mem",
                coltype=Integer(),
                value=self.mem_score(),
                comment=f"Memory (/{MEMORY_MAX})",
            ),
            SummaryElement(
                name="fluency",
                coltype=Integer(),
                value=self.fluency_score(),
                comment=f"Fluency (/{FLUENCY_MAX})",
            ),
            SummaryElement(
                name="lang",
                coltype=Integer(),
                value=self.lang_score(),
                comment=f"Language (/{LANG_MAX})",
            ),
            SummaryElement(
                name="vsp",
                coltype=Integer(),
                value=self.vsp_score(),
                comment=f"Visuospatial (/{VSP_MAX})",
            ),
            SummaryElement(
                name="mini_ace",
                coltype=Integer(),
                value=self.mini_ace_score(),
                comment=f"Mini-ACE (/{MINI_ACE_MAX})",
            ),
        ]

    def attn_score(self) -> int:
        return cast(int, self.sum_fields(self.ATTN_SCORE_FIELDS))

    @staticmethod
    def get_recog_score(
        recalled: Optional[int], recognized: Optional[int]
    ) -> int:
        if recalled == 1:
            return 1
        return score_zero_for_absent(recognized)

    @staticmethod
    def get_recog_text(
        recalled: Optional[int], recognized: Optional[int]
    ) -> str:
        if recalled:
            return "<i>1 (already recalled)</i>"
        return answer(recognized)

    # noinspection PyUnresolvedReferences
    def get_mem_recognition_score(self) -> int:
        score = 0
        score += self.get_recog_score(
            (self.mem_recall_address1 == 1 and self.mem_recall_address2 == 1),  # type: ignore[attr-defined]  # noqa: E501
            self.mem_recognize_address1,  # type: ignore[attr-defined]
        )
        score += self.get_recog_score(
            (self.mem_recall_address3 == 1), self.mem_recognize_address2  # type: ignore[attr-defined]  # noqa: E501
        )
        score += self.get_recog_score(
            (self.mem_recall_address4 == 1 and self.mem_recall_address5 == 1),  # type: ignore[attr-defined]  # noqa: E501
            self.mem_recognize_address3,  # type: ignore[attr-defined]
        )
        score += self.get_recog_score(
            (self.mem_recall_address6 == 1), self.mem_recognize_address4  # type: ignore[attr-defined]  # noqa: E501
        )
        score += self.get_recog_score(
            (self.mem_recall_address7 == 1), self.mem_recognize_address5  # type: ignore[attr-defined]  # noqa: E501
        )
        return score

    def mem_score(self) -> int:
        return cast(
            int,
            (
                self.sum_fields(self.MEM_NON_RECOG_SCORE_FIELDS)
                + self.get_mem_recognition_score()
            ),
        )

    def fluency_score(self) -> int:
        return cast(
            int,
            (
                score_zero_for_absent(self.fluency_letters_score)
                + score_zero_for_absent(self.fluency_animals_score)
            ),
        )

    def get_follow_command_score(self) -> int:
        if self.lang_follow_command_practice != 1:
            return 0
        return cast(int, self.sum_fields(self.LANG_FOLLOW_CMD_FIELDS))

    def get_repeat_word_score(self) -> int:
        n = cast(int, self.sum_fields(self.LANG_REPEAT_WORD_FIELDS))
        return 2 if n >= 4 else (1 if n == 3 else 0)

    def lang_score(self) -> int:
        return (
            cast(int, self.sum_fields(self.LANG_SIMPLE_SCORE_FIELDS))
            + self.get_follow_command_score()
            + self.get_repeat_word_score()
            + score_zero_for_absent(self.lang_read_words_aloud)
        )

    def vsp_score(self) -> int:
        return (
            cast(int, self.sum_fields(self.VSP_SIMPLE_SCORE_FIELDS))
            + score_zero_for_absent(self.vsp_copy_infinity)
            + score_zero_for_absent(self.vsp_copy_cube)
            + score_zero_for_absent(self.vsp_draw_clock)
        )

    def total_score(self) -> int:
        return (
            self.attn_score()
            + self.mem_score()
            + self.fluency_score()
            + self.lang_score()
            + self.vsp_score()
        )

    def mini_ace_score(self) -> int:
        return cast(int, self.sum_fields(self.MINI_ACE_FIELDS))

    # noinspection PyUnresolvedReferences
    def is_recognition_complete(self) -> bool:
        return (
            (
                (
                    self.mem_recall_address1 == 1  # type: ignore[attr-defined]
                    and self.mem_recall_address2 == 1  # type: ignore[attr-defined]  # noqa: E501
                )
                or self.mem_recognize_address1 is not None  # type: ignore[attr-defined]  # noqa: E501
            )
            and (
                self.mem_recall_address3 == 1  # type: ignore[attr-defined]
                or self.mem_recognize_address2 is not None  # type: ignore[attr-defined]  # noqa: E501
            )
            and (
                (
                    self.mem_recall_address4 == 1  # type: ignore[attr-defined]
                    and self.mem_recall_address5 == 1  # type: ignore[attr-defined]  # noqa: E501
                )
                or self.mem_recognize_address3 is not None  # type: ignore[attr-defined]  # noqa: E501
            )
            and (
                self.mem_recall_address6 == 1  # type: ignore[attr-defined]
                or self.mem_recognize_address4 is not None  # type: ignore[attr-defined]  # noqa: E501
            )
            and (
                self.mem_recall_address7 == 1  # type: ignore[attr-defined]
                or self.mem_recognize_address5 is not None  # type: ignore[attr-defined]  # noqa: E501
            )
        )

    def is_complete(self) -> bool:
        if self.any_fields_none(self.BASIC_COMPLETENESS_FIELDS):
            return False
        if not self.field_contents_valid():
            return False
        if self.lang_follow_command_practice == 1 and self.any_fields_none(
            self.LANG_FOLLOW_CMD_FIELDS
        ):
            return False
        return self.is_recognition_complete()

    @classmethod
    def get_target_address_parts(
        cls, req: CamcopsRequest, task_address_version: str
    ) -> List[str]:
        """
        Returns the target address components (7 of them). This requires an
        xstring (via a request also embodying the currently selected locale)
        and the version selected for the task.

        We do this as a classmethod so it (a) saves duplication and (b) knows
        about the xstrings for ACE-III (which are shared with the Mini-ACE). A
        superclass/mixin would be an alternative.
        """
        parts = []  # type: List[str]
        for i in range(1, N_MEM_REPEAT_RECALL_ADDR + 1):
            xstringname = f"task_{task_address_version}_target_address_{i}"
            part = cls.xstring(req, xstringname)
            parts.append(part)
        return parts

    # noinspection PyUnresolvedReferences
    def get_task_html(self, req: CamcopsRequest) -> str:
        a = self.attn_score()
        m = self.mem_score()
        f = self.fluency_score()
        lang = self.lang_score()
        v = self.vsp_score()
        t = a + m + f + lang + v
        mini = self.mini_ace_score()
        target_addr = qsequence(
            self.get_target_address_parts(req, self.task_address_version)
        )
        lkb = qsequence(RECALL_WORDS)  # lemon, key, ball

        if self.is_complete():
            figsize = (
                PlotDefaults.FULLWIDTH_PLOT_WIDTH / 3,
                PlotDefaults.FULLWIDTH_PLOT_WIDTH / 4,
            )
            width = 0.9
            fig = req.create_figure(figsize=figsize)
            ax = fig.add_subplot(1, 1, 1)
            scores = numpy.array([a, m, f, lang, v])
            maxima = numpy.array(
                [ATTN_MAX, MEMORY_MAX, FLUENCY_MAX, LANG_MAX, VSP_MAX]
            )
            y = 100 * scores / maxima
            x_labels = ["Attn", "Mem", "Flu", "Lang", "VSp"]
            # noinspection PyTypeChecker
            n = len(y)
            xvar = numpy.arange(n)
            ax.bar(xvar, y, width, color="b")
            ax.set_ylabel("%", fontdict=req.fontdict)
            ax.set_xticks(xvar)
            x_offset = -0.5
            ax.set_xlim(0 + x_offset, len(scores) + x_offset)
            ax.set_xticklabels(x_labels, fontdict=req.fontdict)
            fig.tight_layout()  # or the ylabel drops off the figure
            # fig.autofmt_xdate()
            req.set_figure_font_sizes(ax)
            figurehtml = req.get_html_from_pyplot_figure(fig)
        else:
            figurehtml = "<i>Incomplete; not plotted</i>"

        return (
            self.get_standard_clinician_comments_block(req, self.comments)
            + f"""
                <div class="{CssClass.SUMMARY}">
                    <table class="{CssClass.SUMMARY}">
                        <tr>
                            {self.get_is_complete_td_pair(req)}
                            <td class="{CssClass.FIGURE}"
                                rowspan="8">{figurehtml}</td>
                        </tr>
            """
            + tr(
                "Total ACE-III score <sup>[1]</sup>",
                answer(t) + f" / {TOTAL_MAX}",
            )
            + tr_score_with_pct("Attention", a, ATTN_MAX)
            + tr_score_with_pct("Memory", m, MEMORY_MAX)
            + tr_score_with_pct("Fluency", f, FLUENCY_MAX)
            + tr_score_with_pct("Language", lang, LANG_MAX)
            + tr_score_with_pct("Visuospatial", v, VSP_MAX)
            + tr_score_with_pct(
                "Mini-ACE score <sup>[2]</sup>", mini, MINI_ACE_MAX
            )
            + f"""
                    </table>
                </div>
                <table class="{CssClass.TASKCONFIG}">
            """
            + tr_heading("Task aspect", "Setting")
            + tr_qa("Edition", self.task_edition)
            + tr_qa("Version", self.task_address_version)
            + tr_qa(
                "Remote administration?",
                get_yes_no_none(req, self.remote_administration),
            )
            + f"""
                <table class="{CssClass.TASKDETAIL}">
            """
            + tr_heading("Question", "Answer/score")
            + tr_qa(
                AGE_FTE,
                self.age_at_leaving_full_time_education,
            )
            + tr_qa(OCCUPATION, ws.webify(self.occupation))
            + tr_qa(HANDEDNESS, ws.webify(self.handedness))
            + subheading_spanning_two_columns("Attention")
            + tr(
                "Day? Date? Month? Year? Season?",
                ", ".join(
                    answer(x)
                    for x in (
                        self.attn_time1,  # type: ignore[attr-defined]
                        self.attn_time2,  # type: ignore[attr-defined]
                        self.attn_time3,  # type: ignore[attr-defined]
                        self.attn_time4,  # type: ignore[attr-defined]
                        self.attn_time5,  # type: ignore[attr-defined]
                    )
                ),
            )
            + tr(
                "House number/floor? Street/hospital? Town? County? Country?",
                ", ".join(
                    answer(x)
                    for x in (
                        self.attn_place1,  # type: ignore[attr-defined]
                        self.attn_place2,  # type: ignore[attr-defined]
                        self.attn_place3,  # type: ignore[attr-defined]
                        self.attn_place4,  # type: ignore[attr-defined]
                        self.attn_place5,  # type: ignore[attr-defined]
                    )
                ),
            )
            + tr(
                "Repeat: " + lkb,
                ", ".join(
                    answer(x)
                    for x in (
                        self.attn_repeat_word1,  # type: ignore[attr-defined]
                        self.attn_repeat_word2,  # type: ignore[attr-defined]
                        self.attn_repeat_word3,  # type: ignore[attr-defined]
                    )
                ),
            )
            + tr(
                "Repetition: number of trials <i>(not scored)</i>",
                answer(
                    self.attn_num_registration_trials, formatter_answer=italic
                ),
            )
            + tr(
                "Serial subtractions: First correct? Second? Third? Fourth? "
                "Fifth?",
                ", ".join(
                    answer(x)
                    for x in (
                        self.attn_serial7_subtraction1,  # type: ignore[attr-defined]  # noqa: E501
                        self.attn_serial7_subtraction2,  # type: ignore[attr-defined]  # noqa: E501
                        self.attn_serial7_subtraction3,  # type: ignore[attr-defined]  # noqa: E501
                        self.attn_serial7_subtraction4,  # type: ignore[attr-defined]  # noqa: E501
                        self.attn_serial7_subtraction5,  # type: ignore[attr-defined]  # noqa: E501
                    )
                ),
            )
            + subheading_spanning_two_columns("Memory (1)")
            + tr(
                "Recall: " + lkb,
                ", ".join(
                    answer(x)
                    for x in (
                        self.mem_recall_word1,  # type: ignore[attr-defined]
                        self.mem_recall_word2,  # type: ignore[attr-defined]
                        self.mem_recall_word3,  # type: ignore[attr-defined]
                    )
                ),
            )
            + subheading_spanning_two_columns("Fluency")
            + tr(
                "Score for words beginning with ‘P’ <i>(≥18 scores 7, 14–17 "
                "scores 6, 11–13 scores 5, 8–10 scores 4, 6–7 scores 3, "
                "4–5 scores 2, 2–3 scores 1, 0–1 scores 0)</i>",
                answer(self.fluency_letters_score) + " / 7",
            )
            + tr(
                ANIMAL_FLUENCY_SCORING_HTML,
                answer(self.fluency_animals_score) + " / 7",
            )
            + subheading_spanning_two_columns("Memory (2)")
            + tr(
                "Third trial of address registration: " + target_addr,
                ", ".join(
                    answer(x)
                    for x in (
                        self.mem_repeat_address_trial3_1,  # type: ignore[attr-defined]  # noqa: E501
                        self.mem_repeat_address_trial3_2,  # type: ignore[attr-defined]  # noqa: E501
                        self.mem_repeat_address_trial3_3,  # type: ignore[attr-defined]  # noqa: E501
                        self.mem_repeat_address_trial3_4,  # type: ignore[attr-defined]  # noqa: E501
                        self.mem_repeat_address_trial3_5,  # type: ignore[attr-defined]  # noqa: E501
                        self.mem_repeat_address_trial3_6,  # type: ignore[attr-defined]  # noqa: E501
                        self.mem_repeat_address_trial3_7,  # type: ignore[attr-defined]  # noqa: E501
                    )
                ),
            )
            + tr(
                "Current PM? First female PM? USA president? USA president "
                "assassinated in 1960s?",
                ", ".join(
                    answer(x)
                    for x in (
                        self.mem_famous1,  # type: ignore[attr-defined]
                        self.mem_famous2,  # type: ignore[attr-defined]
                        self.mem_famous3,  # type: ignore[attr-defined]
                        self.mem_famous4,  # type: ignore[attr-defined]
                    )
                ),
            )
            + subheading_spanning_two_columns("Language")
            + tr(
                "<i>Practice trial (“Pick up the pencil and then the "
                "paper”)</i>",
                answer(
                    self.lang_follow_command_practice, formatter_answer=italic
                ),
            )
            + tr_qa(
                "“Place the paper on top of the pencil”",
                self.lang_follow_command1,  # type: ignore[attr-defined]
            )
            + tr_qa(
                "“Pick up the pencil but not the paper”",
                self.lang_follow_command2,  # type: ignore[attr-defined]
            )
            + tr_qa(
                "“Pass me the pencil after touching the paper”",
                self.lang_follow_command3,  # type: ignore[attr-defined]
            )
            + tr(
                "Sentence-writing: point for 2 complete sentences? "
                "Point for correct grammar and spelling?",
                ", ".join(
                    answer(x)
                    for x in (
                        self.lang_write_sentences_point1,  # type: ignore[attr-defined]  # noqa: E501
                        self.lang_write_sentences_point2,  # type: ignore[attr-defined]  # noqa: E501
                    )
                ),
            )
            + tr(
                "Repeat: caterpillar? eccentricity? unintelligible? "
                "statistician? <i>(score 2 if all correct, 1 if 3 correct, "
                "0 if ≤2 correct)</i>",
                "<i>{}, {}, {}, {}</i> (score <b>{}</b> / 2)".format(
                    answer(self.lang_repeat_word1, formatter_answer=italic),  # type: ignore[attr-defined]  # noqa: E501
                    answer(self.lang_repeat_word2, formatter_answer=italic),  # type: ignore[attr-defined]  # noqa: E501
                    answer(self.lang_repeat_word3, formatter_answer=italic),  # type: ignore[attr-defined]  # noqa: E501
                    answer(self.lang_repeat_word4, formatter_answer=italic),  # type: ignore[attr-defined]  # noqa: E501
                    self.get_repeat_word_score(),
                ),
            )
            + tr_qa(
                "Repeat: “All that glitters is not gold”?",
                self.lang_repeat_sentence1,  # type: ignore[attr-defined]
            )
            + tr_qa(
                "Repeat: “A stitch in time saves nine”?",
                self.lang_repeat_sentence2,  # type: ignore[attr-defined]
            )
            + tr(
                "Name pictures: spoon, book, kangaroo/wallaby",
                ", ".join(
                    answer(x)
                    for x in (
                        self.lang_name_picture1,  # type: ignore[attr-defined]
                        self.lang_name_picture2,  # type: ignore[attr-defined]
                        self.lang_name_picture3,  # type: ignore[attr-defined]
                    )
                ),
            )
            + tr(
                "Name pictures: penguin, anchor, camel/dromedary",
                ", ".join(
                    answer(x)
                    for x in (
                        self.lang_name_picture4,  # type: ignore[attr-defined]
                        self.lang_name_picture5,  # type: ignore[attr-defined]
                        self.lang_name_picture6,  # type: ignore[attr-defined]
                    )
                ),
            )
            + tr(
                "Name pictures: harp, rhinoceros/rhino, barrel/keg/tub",
                ", ".join(
                    answer(x)
                    for x in (
                        self.lang_name_picture7,  # type: ignore[attr-defined]
                        self.lang_name_picture8,  # type: ignore[attr-defined]
                        self.lang_name_picture9,  # type: ignore[attr-defined]
                    )
                ),
            )
            + tr(
                "Name pictures: crown, alligator/crocodile, "
                "accordion/piano accordion/squeeze box",
                ", ".join(
                    answer(x)
                    for x in (
                        self.lang_name_picture10,  # type: ignore[attr-defined]
                        self.lang_name_picture11,  # type: ignore[attr-defined]
                        self.lang_name_picture12,  # type: ignore[attr-defined]
                    )
                ),
            )
            + tr(
                "Identify pictures: monarchy? marsupial? Antarctic? nautical?",
                ", ".join(
                    answer(x)
                    for x in (
                        self.lang_identify_concept1,  # type: ignore[attr-defined]  # noqa: E501
                        self.lang_identify_concept2,  # type: ignore[attr-defined]  # noqa: E501
                        self.lang_identify_concept3,  # type: ignore[attr-defined]  # noqa: E501
                        self.lang_identify_concept4,  # type: ignore[attr-defined]  # noqa: E501
                    )
                ),
            )
            + tr_qa(
                "Read all successfully: sew, pint, soot, dough, height",
                self.lang_read_words_aloud,
            )
            + subheading_spanning_two_columns("Visuospatial")
            + tr("Copy infinity", answer(self.vsp_copy_infinity) + " / 1")
            + tr("Copy cube", answer(self.vsp_copy_cube) + " / 2")
            + tr(
                "Draw clock with numbers and hands at 5:10",
                answer(self.vsp_draw_clock) + " / 5",
            )
            + tr(
                "Count dots: 8, 10, 7, 9",
                ", ".join(
                    answer(x)
                    for x in (
                        self.vsp_count_dots1,  # type: ignore[attr-defined]
                        self.vsp_count_dots2,  # type: ignore[attr-defined]
                        self.vsp_count_dots3,  # type: ignore[attr-defined]
                        self.vsp_count_dots4,  # type: ignore[attr-defined]
                    )
                ),
            )
            + tr(
                "Identify letters: K, M, A, T",
                ", ".join(
                    answer(x)
                    for x in (
                        self.vsp_identify_letter1,  # type: ignore[attr-defined]  # noqa: E501
                        self.vsp_identify_letter2,  # type: ignore[attr-defined]  # noqa: E501
                        self.vsp_identify_letter3,  # type: ignore[attr-defined]  # noqa: E501
                        self.vsp_identify_letter4,  # type: ignore[attr-defined]  # noqa: E501
                    )
                ),
            )
            + subheading_spanning_two_columns("Memory (3)")
            + tr(
                "Recall address: " + target_addr,
                ", ".join(
                    answer(x)
                    for x in (
                        self.mem_recall_address1,  # type: ignore[attr-defined]
                        self.mem_recall_address2,  # type: ignore[attr-defined]
                        self.mem_recall_address3,  # type: ignore[attr-defined]
                        self.mem_recall_address4,  # type: ignore[attr-defined]
                        self.mem_recall_address5,  # type: ignore[attr-defined]
                        self.mem_recall_address6,  # type: ignore[attr-defined]
                        self.mem_recall_address7,  # type: ignore[attr-defined]
                    )
                ),
            )
            + tr(
                "Recognize address: forename and surname?",
                self.get_recog_text(
                    (
                        self.mem_recall_address1 == 1  # type: ignore[attr-defined]  # noqa: E501
                        and self.mem_recall_address2 == 1  # type: ignore[attr-defined]  # noqa: E501
                    ),
                    self.mem_recognize_address1,  # type: ignore[attr-defined]
                ),
            )
            + tr(
                "Recognize address: house number?",
                self.get_recog_text(
                    (self.mem_recall_address3 == 1),  # type: ignore[attr-defined]  # noqa: E501
                    self.mem_recognize_address2,  # type: ignore[attr-defined]
                ),
            )
            + tr(
                "Recognize address: street?",
                self.get_recog_text(
                    (
                        self.mem_recall_address4 == 1  # type: ignore[attr-defined]  # noqa: E501
                        and self.mem_recall_address5 == 1  # type: ignore[attr-defined]  # noqa: E501
                    ),
                    self.mem_recognize_address3,  # type: ignore[attr-defined]
                ),
            )
            + tr(
                "Recognize address: town?",
                self.get_recog_text(
                    (self.mem_recall_address6 == 1),  # type: ignore[attr-defined]  # noqa: E501
                    self.mem_recognize_address4,  # type: ignore[attr-defined]
                ),
            )
            + tr(
                "Recognize address: county?",
                self.get_recog_text(
                    (self.mem_recall_address7 == 1),  # type: ignore[attr-defined]  # noqa: E501
                    self.mem_recognize_address5,  # type: ignore[attr-defined]
                ),
            )
            + subheading_spanning_two_columns("Photos of test sheet")
            + tr_span_col(
                get_blob_img_html(self.picture1), td_class=CssClass.PHOTO
            )
            + tr_span_col(
                get_blob_img_html(self.picture2), td_class=CssClass.PHOTO
            )
            + f"""
                </table>
                <div class="{CssClass.FOOTNOTES}">
                    [1] In the ACE-III, scores ≤82 had sensitivity 0.93 and
                    specificity 1.0 for dementia, and scores ≤88 had
                    sensitivity 1.0 and specificity 0.98 for dementia, in a
                    context of patients with Alzheimer’s disease,
                    frontotemporal dementia, and controls (Hsieh et al. 2013,
                    {pmid(23949210)}).

                    [2] {MINI_ACE_THRESHOLDS}
                </div>
                <div class="{CssClass.COPYRIGHT}">
                    {ACE3_COPYRIGHT}
                </div>
            """
        )

    def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]:
        codes = [
            SnomedExpression(
                req.snomed(SnomedLookup.ACE_R_PROCEDURE_ASSESSMENT)
            )
        ]
        # add(SnomedLookup.ACE_R_PROCEDURE_ASSESSMENT_SUBSCALE_ATTENTION_ORIENTATION)  # noqa
        # add(SnomedLookup.ACE_R_PROCEDURE_ASSESSMENT_SUBSCALE_MEMORY)
        # add(SnomedLookup.ACE_R_PROCEDURE_ASSESSMENT_SUBSCALE_FLUENCY)
        # add(SnomedLookup.ACE_R_PROCEDURE_ASSESSMENT_SUBSCALE_LANGUAGE)
        # add(SnomedLookup.ACE_R_PROCEDURE_ASSESSMENT_SUBSCALE_VISUOSPATIAL)
        if self.is_complete():  # could refine: is each subscale complete?
            a = self.attn_score()
            m = self.mem_score()
            f = self.fluency_score()
            lang = self.lang_score()
            v = self.vsp_score()
            t = a + m + f + lang + v
            codes.append(
                SnomedExpression(
                    req.snomed(SnomedLookup.ACE_R_SCALE),
                    {
                        req.snomed(SnomedLookup.ACE_R_SCORE): t,
                        req.snomed(
                            SnomedLookup.ACE_R_SUBSCORE_ATTENTION_ORIENTATION
                        ): a,
                        req.snomed(SnomedLookup.ACE_R_SUBSCORE_MEMORY): m,
                        req.snomed(SnomedLookup.ACE_R_SUBSCORE_FLUENCY): f,
                        req.snomed(SnomedLookup.ACE_R_SUBSCORE_LANGUAGE): lang,
                        req.snomed(
                            SnomedLookup.ACE_R_SUBSCORE_VISUOSPATIAL
                        ): v,
                    },
                )
            )
        # There's no mini-ACE code in SNOMED-CT yet, as of 2022-12-01.
        return codes


# =============================================================================
# Mini-ACE
# =============================================================================


class MiniAce(  # type: ignore[misc]
    TaskHasPatientMixin,
    TaskHasClinicianMixin,
    Task,
):
    """
    Server implementation of the Mini-ACE task.
    """

    __tablename__ = "miniace"
    shortname = "Mini-ACE"
    extrastring_taskname = "ace3"  # shares strings with ACE-III
    provides_trackers = True

    prohibits_commercial = True

    @classmethod
    def extend_columns(cls: Type["MiniAce"], **kwargs: Any) -> None:
        add_multiple_columns(
            cls,
            "attn_time",
            1,
            N_ATTN_TIME_MINIACE,  # 4, not 5
            pv=PV.BIT,
            comment_fmt="Attention, time, {n}/4, {s} (0 or 1)",
            comment_strings=["day", "date", "month", "year"],  # not season
        )
        add_multiple_columns(
            cls,
            "mem_repeat_address_trial1_",
            1,
            N_MEM_REPEAT_RECALL_ADDR,
            pv=PV.BIT,
            comment_fmt="Memory, address registration trial 1/3 "
            "(not scored), {s} (0 or 1)",
            comment_strings=ADDRESS_PARTS,
        )
        add_multiple_columns(
            cls,
            "mem_repeat_address_trial2_",
            1,
            N_MEM_REPEAT_RECALL_ADDR,
            pv=PV.BIT,
            comment_fmt="Memory, address registration trial 2/3 "
            "(not scored), {s} (0 or 1)",
            comment_strings=ADDRESS_PARTS,
        )
        add_multiple_columns(
            cls,
            "mem_repeat_address_trial3_",
            1,
            N_MEM_REPEAT_RECALL_ADDR,
            pv=PV.BIT,
            comment_fmt="Memory, address registration trial 3/3 "
            "(scored), {s} (0 or 1)",
            comment_strings=ADDRESS_PARTS,
        )
        add_multiple_columns(
            cls,
            "mem_recall_address",
            1,
            N_MEM_REPEAT_RECALL_ADDR,
            pv=PV.BIT,
            comment_fmt="Memory, recall address {n}/7, {s} (0-1)",
            comment_strings=ADDRESS_PARTS,
        )

    task_edition: Mapped[Optional[str]] = mapped_camcops_column(
        String(length=255),
        comment="Task edition.",
    )
    task_address_version: Mapped[Optional[str]] = mapped_camcops_column(
        String(length=1),
        comment="Task version, determining the address for recall (A/B/C).",
        permitted_value_checker=PermittedValueChecker(
            permitted_values=["A", "B", "C"]
        ),
    )
    remote_administration: Mapped[Optional[bool]] = mapped_camcops_column(
        permitted_value_checker=BIT_CHECKER,
        comment="Task performed using remote (videoconferencing) "
        "administration?",
    )
    age_at_leaving_full_time_education: Mapped[Optional[int]] = mapped_column(
        comment="Age at leaving full time education",
    )
    occupation: Mapped[Optional[str]] = mapped_column(
        "occupation", UnicodeText, comment=OCCUPATION
    )
    handedness: Mapped[Optional[str]] = mapped_camcops_column(
        String(length=1),  # was Text
        comment="Handedness (L or R)",
        permitted_value_checker=PermittedValueChecker(
            permitted_values=["L", "R"]
        ),
    )
    fluency_animals_score: Mapped[Optional[int]] = mapped_camcops_column(
        comment="Fluency, animals, score 0-7",
        permitted_value_checker=PermittedValueChecker(minimum=0, maximum=7),
    )
    vsp_draw_clock: Mapped[Optional[int]] = mapped_camcops_column(
        comment="Visuospatial, draw clock (0-5)",
        permitted_value_checker=PermittedValueChecker(minimum=0, maximum=5),
    )
    picture1_blobid: Mapped[Optional[int]] = mapped_camcops_column(
        comment="Photo 1/2 PNG BLOB ID",
        is_blob_id_field=True,
        blob_relationship_attr_name="picture1",
    )
    picture2_blobid: Mapped[Optional[int]] = mapped_camcops_column(
        comment="Photo 2/2 PNG BLOB ID",
        is_blob_id_field=True,
        blob_relationship_attr_name="picture2",
    )
    comments: Mapped[Optional[str]] = mapped_column(
        "comments", UnicodeText, comment="Clinician's comments"
    )

    picture1 = blob_relationship(  # type: ignore[assignment]
        "MiniAce", "picture1_blobid"
    )  # type: Optional[Blob]
    picture2 = blob_relationship(  # type: ignore[assignment]
        "MiniAce", "picture2_blobid"
    )  # type: Optional[Blob]

    MACE_ATTN_FIELDS = strseq("attn_time", 1, N_ATTN_TIME_MINIACE)  # 4 points
    MACE_MEMORY_FIELDS = strseq("mem_repeat_address_trial3_", 1, 7) + strseq(
        "mem_recall_address", 1, 7
    )  # 14 points
    MACE_FLUENCY_FIELDS = ["fluency_animals_score"]  # 7 points
    MACE_VSP_FIELDS = ["vsp_draw_clock"]  # 5 points
    MINI_ACE_FIELDS = (
        MACE_ATTN_FIELDS
        + MACE_MEMORY_FIELDS
        + MACE_FLUENCY_FIELDS
        + MACE_VSP_FIELDS
    )

    @staticmethod
    def longname(req: "CamcopsRequest") -> str:
        _ = req.gettext
        return _("Mini-Addenbrooke’s Cognitive Examination")

    def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
        return [
            TrackerInfo(
                value=self.mini_ace_score(),
                plot_label="Mini-ACE score",
                axis_label=f"Mini-ACE score (out of {MINI_ACE_MAX})",
                axis_min=-0.5,
                axis_max=MINI_ACE_MAX + 0.5,
                # Traditional cutoffs: ≤21, ≤25
                horizontal_lines=[21.5, 25.5],
            ),
        ]

    def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
        if not self.is_complete():
            return CTV_INCOMPLETE
        a = self.attn_score()
        m = self.mem_score()
        f = self.fluency_score()
        v = self.vsp_score()
        mini = a + m + f + v
        text = (
            f"Mini-ACE score: {mini}/{MINI_ACE_MAX} "
            f"(attention {a}/{ATTN_MINIACE_MAX}, "
            f"memory {m}/{MEM_MINIACE_MAX}, "
            f"fluency {f}/{FLUENCY_MINIACE_MAX}, "
            f"visuospatial {v}/{VSP_MINIACE_MAX})"
        )
        return [CtvInfo(content=text)]

    def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
        return self.standard_task_summary_fields() + [
            SummaryElement(
                name="mini_ace",
                coltype=Integer(),
                value=self.mini_ace_score(),
                comment=f"Mini-ACE (/{MINI_ACE_MAX})",
            ),
        ]

    def attn_score(self) -> int:
        return cast(int, self.sum_fields(self.MACE_ATTN_FIELDS))

    def mem_score(self) -> int:
        return cast(int, self.sum_fields(self.MACE_MEMORY_FIELDS))

    def fluency_score(self) -> int:
        return cast(int, self.sum_fields(self.MACE_FLUENCY_FIELDS))

    def vsp_score(self) -> int:
        return cast(int, self.sum_fields(self.MACE_VSP_FIELDS))

    def mini_ace_score(self) -> int:
        return cast(int, self.sum_fields(self.MINI_ACE_FIELDS))

    def is_complete(self) -> bool:
        return (
            self.all_fields_not_none(self.MINI_ACE_FIELDS)
            and self.field_contents_valid()
        )

    def get_task_html(self, req: CamcopsRequest) -> str:
        a = self.attn_score()
        m = self.mem_score()
        f = self.fluency_score()
        v = self.vsp_score()
        mini = a + m + f + v
        target_addr = qsequence(
            Ace3.get_target_address_parts(req, self.task_address_version)
        )

        if self.is_complete():
            figsize = (
                PlotDefaults.FULLWIDTH_PLOT_WIDTH / 3,
                PlotDefaults.FULLWIDTH_PLOT_WIDTH / 4,
            )
            width = 0.9
            fig = req.create_figure(figsize=figsize)
            ax = fig.add_subplot(1, 1, 1)
            scores = numpy.array([a, m, f, v])
            maxima = numpy.array(
                [
                    ATTN_MINIACE_MAX,
                    MEM_MINIACE_MAX,
                    FLUENCY_MINIACE_MAX,
                    VSP_MINIACE_MAX,
                ]
            )
            y = 100 * scores / maxima
            x_labels = ["Attn", "Mem", "Flu", "VSp"]
            # noinspection PyTypeChecker
            n = len(y)
            xvar = numpy.arange(n)
            ax.bar(xvar, y, width, color="g")
            ax.set_ylabel("%", fontdict=req.fontdict)
            ax.set_xticks(xvar)
            x_offset = -0.5
            ax.set_xlim(0 + x_offset, len(scores) + x_offset)
            ax.set_xticklabels(x_labels, fontdict=req.fontdict)
            fig.tight_layout()  # or the ylabel drops off the figure
            # fig.autofmt_xdate()
            req.set_figure_font_sizes(ax)
            figurehtml = req.get_html_from_pyplot_figure(fig)
        else:
            figurehtml = "<i>Incomplete; not plotted</i>"

        return (
            self.get_standard_clinician_comments_block(req, self.comments)
            + f"""
                <div class="{CssClass.SUMMARY}">
                    <table class="{CssClass.SUMMARY}">
                        <tr>
                            {self.get_is_complete_td_pair(req)}
                            <td class="{CssClass.FIGURE}"
                                rowspan="6">{figurehtml}</td>
                        </tr>
            """
            + tr_score_with_pct(
                "Mini-ACE score <sup>[1]</sup>", mini, MINI_ACE_MAX
            )
            + tr_score_with_pct("Attention", a, ATTN_MINIACE_MAX)
            + tr_score_with_pct("Memory", m, MEM_MINIACE_MAX)
            + tr_score_with_pct("Fluency", f, FLUENCY_MINIACE_MAX)
            + tr_score_with_pct("Visuospatial", v, VSP_MINIACE_MAX)
            + f"""
                    </table>
                </div>
                <table class="{CssClass.TASKCONFIG}">
            """
            + tr_heading("Task aspect", "Setting")
            + tr_qa("Edition", self.task_edition)
            + tr_qa("Version", self.task_address_version)
            + tr_qa(
                "Remote administration?",
                get_yes_no_none(req, self.remote_administration),
            )
            + f"""
                <table class="{CssClass.TASKDETAIL}">
            """
            + tr_heading("Question", "Answer/score")
            + tr_qa(
                AGE_FTE,
                self.age_at_leaving_full_time_education,
            )
            + tr_qa(OCCUPATION, ws.webify(self.occupation))
            + tr_qa(HANDEDNESS, ws.webify(self.handedness))
            + subheading_spanning_two_columns("Attention")
            + tr(
                "Day? Date? Month? Year?",  # not season
                ", ".join(
                    answer(x)
                    for x in (
                        self.attn_time1,  # type: ignore[attr-defined]
                        self.attn_time2,  # type: ignore[attr-defined]
                        self.attn_time3,  # type: ignore[attr-defined]
                        self.attn_time4,  # type: ignore[attr-defined]
                    )
                ),
            )
            + subheading_spanning_two_columns("Memory")
            + tr(
                "Third trial of address registration: " + target_addr,
                ", ".join(
                    answer(x)
                    for x in (
                        self.mem_repeat_address_trial3_1,  # type: ignore[attr-defined]  # noqa: E501
                        self.mem_repeat_address_trial3_2,  # type: ignore[attr-defined]  # noqa: E501
                        self.mem_repeat_address_trial3_3,  # type: ignore[attr-defined]  # noqa: E501
                        self.mem_repeat_address_trial3_4,  # type: ignore[attr-defined]  # noqa: E501
                        self.mem_repeat_address_trial3_5,  # type: ignore[attr-defined]  # noqa: E501
                        self.mem_repeat_address_trial3_6,  # type: ignore[attr-defined]  # noqa: E501
                        self.mem_repeat_address_trial3_7,  # type: ignore[attr-defined]  # noqa: E501
                    )
                ),
            )
            + subheading_spanning_two_columns("Fluency – animals")
            + tr(
                ANIMAL_FLUENCY_SCORING_HTML,
                answer(self.fluency_animals_score) + " / 7",
            )
            + subheading_spanning_two_columns("Clock drawing")
            + tr(
                "Draw clock with numbers and hands at 5:10",
                answer(self.vsp_draw_clock) + " / 5",
            )
            + subheading_spanning_two_columns("Memory recall")
            + tr(
                "Recall address: " + target_addr,
                ", ".join(
                    answer(x)
                    for x in (
                        self.mem_recall_address1,  # type: ignore[attr-defined]
                        self.mem_recall_address2,  # type: ignore[attr-defined]
                        self.mem_recall_address3,  # type: ignore[attr-defined]
                        self.mem_recall_address4,  # type: ignore[attr-defined]
                        self.mem_recall_address5,  # type: ignore[attr-defined]
                        self.mem_recall_address6,  # type: ignore[attr-defined]
                        self.mem_recall_address7,  # type: ignore[attr-defined]
                    )
                ),
            )
            + subheading_spanning_two_columns("Photos of test sheet")
            + tr_span_col(
                get_blob_img_html(self.picture1), td_class=CssClass.PHOTO
            )
            + tr_span_col(
                get_blob_img_html(self.picture2), td_class=CssClass.PHOTO
            )
            + f"""
                </table>
                <div class="{CssClass.FOOTNOTES}">
                    [1] {MINI_ACE_THRESHOLDS}
                </div>
                <div class="{CssClass.COPYRIGHT}">
                    {ACE3_COPYRIGHT}
                </div>
            """
        )
