from collections import defaultdict
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from dataclasses import field as dataclass_field
from functools import partial
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID

from flask import current_app
from flask_wtf import FlaskForm
from flask_wtf.file import FileAllowed, FileField, FileRequired, FileSize
from govuk_frontend_wtf.wtforms_widgets import (
    GovCharacterCount,
    GovDateInput,
    GovFileInput,
    GovRadioInput,
    GovSubmitInput,
    GovTextArea,
    GovTextInput,
)
from wtforms import DateField, Field, Form, RadioField
from wtforms.fields.choices import SelectField, SelectMultipleField
from wtforms.fields.numeric import DecimalField, IntegerField
from wtforms.fields.simple import EmailField, StringField, SubmitField
from wtforms.validators import DataRequired, Email, InputRequired, Optional, ValidationError

from app.common.data.models import Expression, Group, Question
from app.common.data.types import NumberTypeEnum, QuestionDataType
from app.common.expressions import (
    ExpressionContext,
    evaluate,
    interpolate,
)
from app.common.forms.fields import (
    DecimalWithCommasField,
    IntegerWithCommasField,
    MHCLGAccessibleAutocomplete,
    MHCLGApproximateDateInput,
    MHCLGCheckboxesInput,
    MHCLGRadioInput,
)
from app.common.forms.validators import (
    FinalOptionExclusive,
    MaxNumberOfDecimalPlacesValidator,
    URLWithoutProtocol,
    WordRange,
)
from app.metrics import MetricEventName, emit_metric_count

if TYPE_CHECKING:
    from app.common.collections.types import AllAnswerTypes
    from app.common.helpers.collections import SubmissionHelper

_accepted_fields = (
    EmailField
    | StringField
    | IntegerField
    | IntegerWithCommasField
    | RadioField
    | SelectField
    | SelectMultipleField
    | DateField
    | DecimalField
    | FileField
)


@dataclass
class ErrorListEntry:
    question: Question
    answer: str
    input_href: str

    @property
    def error_message(self) -> str:
        return f"Check {self.question.name}"


@dataclass
class DataSetReferenceEntry:
    column_name: str
    value: str

    @property
    def error_message(self) -> str:
        return f"{self.column_name} ({self.value})"


@dataclass
class GroupValidationError:
    message: str
    on_page_entries: list[ErrorListEntry] = dataclass_field(default_factory=list)
    off_page_entries: list[ErrorListEntry] = dataclass_field(default_factory=list)
    data_set_entries: list[DataSetReferenceEntry] = dataclass_field(default_factory=list)


# FIXME: Ideally this would do an intersection between FlaskForm and QuestionFormProtocol, but type hinting in
#        python doesn't currently support this. As of May 2025, it looks like we might be close to some progress on
#        this in https://github.com/python/typing/issues/213.
# This is a bit of a hack so that we have an externally-accessible type that represents the kind of form returned
# by `build_question_form`. This gives us nicer intellisense/etc. The downside is that this class needs to be kept
# in sync manually with the one inside `build_question_form`.
class DynamicQuestionForm(FlaskForm):
    _evaluation_context: ExpressionContext
    _interpolation_context: ExpressionContext
    _questions: list[Question]
    _component: Question | Group | None = None
    _submission_helper: "SubmissionHelper | None" = None

    group_validation_error: GroupValidationError | None = None

    submit: SubmitField

    def _extract_submission_answers(self) -> dict[str, Any]:
        """
        Extract all of the data from the form and return a dict suitable for using in an ExpressionContext instance.
        This data will override any data from the existing submission to allow for evaluations against the most
        up-to-date data (ie from the submission as a whole, plus the data the user has just submitted as part of this
        form.
        """
        # fixme: when adding multi-field/complex question support, we'll need to think more carefully about
        #        transforming the data here to a format that matches our serialised submission format (which passes
        #        through pydantic models. Otherwise we risk exposing different views of the data to the expressions
        #        system (fully serialised+normalised in the submission context, and more raw in the form context).
        # todo: if allowing validations for questions on the same page or supporting optional questions this will
        #       need to consider what happens for expression context evaluations if the answer is being set to None
        data = {k: v for k, v in self.data.items() if k not in {"csrf_token", "submit"}}
        return data

    def validate(self, extra_validators: Mapping[str, list[Any]] | None = None) -> Any:  # ty: ignore[invalid-method-override]
        """
        Run the form's validation chain. This works in three steps:
        - WTForm's built-in field-level validation (eg for IntegerField, that data has been provided, and that it
          can be coerced to an integer value.
        - Our own per-question validation based on the expression framework (managed + custom validations).
        - For same-page groups, any group-level validation rules attached to the group itself.
        """
        # Run the native WTForm field validation, which will do things like check data types are correct (eg for
        # IntegerFields.
        super().validate(extra_validators)

        extra_validators = defaultdict(list, extra_validators or {})

        # Inject the latest data from this form submission into the context for validators to use. This will override
        # any existing data for expression contexts from the current state of the submission with the data submitted
        # in this form by the user.
        submitted_evaluation_context = self._evaluation_context.with_question_form_context(
            self._extract_submission_answers()
        ).with_default_context(self._submission_helper)

        for q in self._questions:
            # only add custom validators if that question hasn't already failed basic validation
            # (it's may not be well formed data of that type)
            if not self[q.safe_qid].errors:
                extra_validators[q.safe_qid].extend(
                    build_validators(q, submitted_evaluation_context, self._interpolation_context)
                )

        # Do a second validation pass that includes all of our managed/custom validation. This has a small bit of
        # redundancy because it will run the data validation checks again, but it means that all of our own
        # validators can rely on the data being, at the least, the right shape.
        individual_validation_passed = super().validate(extra_validators)

        if not individual_validation_passed:
            return False

        if self._component is not None and self._component.is_group and self._component.validations:
            return self._validate_group(submitted_evaluation_context=submitted_evaluation_context)

        return True

    def _validate_group(self, submitted_evaluation_context: ExpressionContext) -> bool:
        """
        Evaluate the validation expressions attached to a group, in order, stopping at the first failure.

        Returns True if all rules pass.
        Returns False after building self.group_validation_error and attaching field-level error markers to the
        on-page question fields that contributed to the failure.
        """
        # this should never happen because the caller gates on this — narrow for type checker / safety
        assert self._component is not None and self._component.is_group

        group = cast(Group, self._component)

        for validation in group.validations:
            if evaluate(
                expression=validation,
                context=submitted_evaluation_context.with_default_context(self._submission_helper),
            ):
                emit_metric_count(
                    MetricEventName.SUBMISSION_MANAGED_VALIDATION_SUCCESS
                    if validation.is_managed
                    else MetricEventName.SUBMISSION_CUSTOM_VALIDATION_SUCCESS,
                    evaluatable_expression=validation.evaluatable_expression,
                )

            else:
                emit_metric_count(
                    MetricEventName.SUBMISSION_MANAGED_VALIDATION_ERROR
                    if validation.is_managed
                    else MetricEventName.SUBMISSION_CUSTOM_VALIDATION_ERROR,
                    evaluatable_expression=validation.evaluatable_expression,
                )

                self.group_validation_error = self._build_group_validation_error(validation=validation)
                return False

        return True

    def _build_group_validation_error(
        self,
        validation: Expression,
    ) -> GroupValidationError:
        from app.common.helpers.collections import _form_data_to_question_type

        on_page_question_ids = {q.id for q in self._questions}

        message = interpolate(
            validation.evaluatable_expression.message,
            context=self._interpolation_context.with_question_form_context(self._extract_submission_answers()),
        )
        error = GroupValidationError(message=message)

        seen_on_page: set[UUID] = set()
        seen_off_page: set[UUID] = set()

        for reference in sorted(
            filter(
                lambda r: r.depends_on_component and r.depends_on_component.is_question, validation.component_references
            ),
            key=lambda r: (
                r.depends_on_component.form.order,
                r.depends_on_component.form.global_component_index(r.depends_on_component),
            ),
        ):
            referenced = reference.depends_on_component
            if referenced is None or not referenced.is_question:
                continue

            referenced_question = cast(Question, referenced)

            if referenced_question.id in on_page_question_ids:
                if referenced_question.id in seen_on_page:
                    continue
                seen_on_page.add(referenced_question.id)

                answer_model: AllAnswerTypes | None = _form_data_to_question_type(referenced_question, self)
                display_value = answer_model.get_value_for_text_export() if answer_model is not None else "not answered"

                error_entry = ErrorListEntry(
                    question=referenced_question,
                    answer=display_value,
                    input_href=f"#{referenced_question.safe_qid}",
                )
                error.on_page_entries.append(error_entry)
                self.attach_error_for_question(referenced_question, error_entry.error_message)

            else:
                if referenced_question.id in seen_off_page:
                    continue

                # don't show referenced questions that the user hasn't had to answer
                if (
                    self._submission_helper
                    and referenced_question.id not in self._submission_helper.all_visible_questions
                ):
                    continue

                seen_off_page.add(referenced_question.id)

                display_value = "not answered"
                if self._submission_helper is not None:
                    persisted_answer = self._submission_helper.cached_get_answer_for_question(referenced_question.id)
                    if persisted_answer is not None:
                        display_value = persisted_answer.get_value_for_text_export()

                error_entry = ErrorListEntry(
                    question=referenced_question,
                    answer=display_value,
                    input_href=f"#{referenced_question.safe_qid}",
                )
                error.off_page_entries.append(error_entry)

        for reference in validation.component_references:
            data_source = reference.depends_on_data_source
            column_name = reference.depends_on_column_name

            if not (data_source and column_name):
                continue
            if not data_source.schema or not (column := data_source.schema.root.get(column_name)):
                raise ValueError(
                    f"ComponentReference {reference.id} in validation {validation.id} points to column {column_name} "
                    f"on data source {data_source.id} but column does not exist in the schema."
                )

            assert self._submission_helper is not None
            data_source_org_item = data_source.get_filtered_organisation_item(
                self._submission_helper.grant_recipient.organisation.external_id
            )
            assert data_source_org_item is not None and isinstance(data_source_org_item.data, dict)
            org_item_column_value = data_source_org_item.data.get(column_name)
            assert org_item_column_value is not None

            error.data_set_entries.append(
                DataSetReferenceEntry(
                    column_name=column.original_column_name,
                    value=org_item_column_value.get_value_for_text_export(),
                )
            )

        return error

    @classmethod
    def attach_field(cls, question: Question, field: Field) -> None:
        setattr(cls, question.safe_qid, cast(_accepted_fields, field))

    def render_question(self, question: Question, params: dict[str, Any] | None = None) -> str:
        return cast(str, getattr(self, question.safe_qid)(params=params))

    def get_question_field(self, question: Question) -> Field:
        return cast(Field, getattr(self, question.safe_qid))

    def get_answer_to_question(self, question: Question) -> Any:
        return getattr(self, question.safe_qid).data

    def attach_error_for_question(self, question: Question, error: str) -> None:
        getattr(self, question.safe_qid).errors.append(error)


def build_validators(
    question: Question, evaluation_context: ExpressionContext, interpolation_context: ExpressionContext
) -> list[Callable[[Form, Field], None]]:
    validators = []

    for _validation in question.validations:

        def run_validation(form: Form, field: Field, validation: Expression) -> None:
            if not evaluate(expression=validation, context=evaluation_context):
                emit_metric_count(
                    MetricEventName.SUBMISSION_MANAGED_VALIDATION_ERROR
                    if validation.is_managed
                    else MetricEventName.SUBMISSION_CUSTOM_VALIDATION_ERROR,
                    evaluatable_expression=validation.evaluatable_expression,
                )
                raise ValidationError(
                    interpolate(validation.evaluatable_expression.message, context=interpolation_context)
                )

            emit_metric_count(
                MetricEventName.SUBMISSION_MANAGED_VALIDATION_SUCCESS
                if validation.is_managed
                else MetricEventName.SUBMISSION_CUSTOM_VALIDATION_SUCCESS,
                evaluatable_expression=validation.evaluatable_expression,
            )

        validators.append(cast(Callable[[Form, Field], None], partial(run_validation, validation=_validation)))

    return validators


def build_question_form(  # noqa: C901
    questions: list[Question],
    evaluation_context: ExpressionContext,
    interpolation_context: ExpressionContext,
    component: "Question | Group | None" = None,
    submission_helper: "SubmissionHelper | None" = None,
) -> type[DynamicQuestionForm]:
    # NOTE: Keep the fields+types in sync with the class of the same name above.
    class _DynamicQuestionForm(DynamicQuestionForm):  # noqa
        _evaluation_context = evaluation_context
        _interpolation_context = interpolation_context
        _questions = questions
        _component = component
        _submission_helper = submission_helper

        submit = SubmitField("Continue", widget=GovSubmitInput())

    field: _accepted_fields
    for question in questions:
        match question.data_type:
            case QuestionDataType.EMAIL:
                field = EmailField(
                    label=interpolate(text=question.text, context=interpolation_context),
                    description=interpolate(text=question.hint, context=interpolation_context),
                    widget=GovTextInput(),
                    validators=[
                        DataRequired(f"Enter the {question.name}"),
                        Email(message="Enter an email address in the correct format, like name@example.com"),
                    ],
                    filters=[lambda x: x.strip() if x else x],
                )
            case QuestionDataType.TEXT_SINGLE_LINE:
                field = StringField(
                    label=interpolate(text=question.text, context=interpolation_context),
                    description=interpolate(text=question.hint, context=interpolation_context),
                    widget=GovTextInput(),
                    validators=[DataRequired(f"Enter the {question.name}")],
                    filters=[lambda x: x.strip() if x else x],
                )
            case QuestionDataType.TEXT_MULTI_LINE:
                field = StringField(
                    label=interpolate(text=question.text, context=interpolation_context),
                    description=interpolate(text=question.hint, context=interpolation_context),
                    widget=GovCharacterCount() if question.presentation_options.word_limit else GovTextArea(),
                    validators=[DataRequired(f"Enter the {question.name}")]
                    + (
                        [
                            WordRange(
                                max_words=question.presentation_options.word_limit, field_display_name=question.name
                            )
                        ]
                        if question.presentation_options.word_limit
                        else []
                    ),
                    filters=[lambda x: x.strip() if x else x],
                )
            case QuestionDataType.NUMBER:
                if question.data_options.number_type == NumberTypeEnum.DECIMAL:
                    field = DecimalWithCommasField(
                        label=interpolate(text=question.text, context=interpolation_context),
                        description=interpolate(text=question.hint, context=interpolation_context),
                        places=None,
                        rounding=None,
                        # We are allowing commas for thousands separators but not full locale-aware formatting
                        use_locale=False,
                        validators=[
                            InputRequired(f"Enter the {question.name}"),
                            MaxNumberOfDecimalPlacesValidator(
                                max_decimal_places=question.data_options.max_decimal_places,  # ty: ignore[invalid-argument-type]
                            ),
                        ],
                        widget=GovTextInput(),
                    )
                else:
                    field = IntegerWithCommasField(
                        label=interpolate(text=question.text, context=interpolation_context),
                        description=interpolate(text=question.hint, context=interpolation_context),
                        widget=GovTextInput(),
                        validators=[InputRequired(f"Enter the {question.name}")],
                    )
            case QuestionDataType.YES_NO:
                field = RadioField(
                    label=interpolate(text=question.text, context=interpolation_context),
                    description=interpolate(text=question.hint, context=interpolation_context),
                    widget=GovRadioInput(),
                    choices=[(1, "Yes"), (0, "No")],
                    validators=[InputRequired("Select yes or no")],
                    coerce=lambda val: bool(int(val)),
                )
            case QuestionDataType.RADIOS:
                assert question.data_source is not None
                if len(question.data_source.items) > current_app.config["ENHANCE_RADIOS_TO_AUTOCOMPLETE_AFTER_X_ITEMS"]:
                    fallback_option = (
                        question.data_source.items[-1].label if question.separate_option_if_no_items_match else None
                    )
                    field = SelectField(
                        label=interpolate(text=question.text, context=interpolation_context),
                        description=interpolate(text=question.hint, context=interpolation_context),
                        widget=MHCLGAccessibleAutocomplete(fallback_option=fallback_option),
                        choices=[("", "")] + [(item.key, item.label) for item in question.data_source.items],
                        validators=[DataRequired(f"Select the {question.name}")],
                    )
                else:
                    choices = [(item.key, item.label) for item in question.data_source.items]
                    field = RadioField(
                        label=interpolate(text=question.text, context=interpolation_context),
                        description=interpolate(text=question.hint, context=interpolation_context),
                        widget=MHCLGRadioInput(
                            insert_divider_before_last_item=bool(question.separate_option_if_no_items_match)
                        ),
                        choices=choices,
                        validators=[DataRequired(f"Select the {question.name}")],
                    )
            case QuestionDataType.URL:
                field = StringField(
                    label=interpolate(text=question.text, context=interpolation_context),
                    description=interpolate(text=question.hint, context=interpolation_context),
                    widget=GovTextInput(),
                    validators=[
                        DataRequired(f"Enter the {question.name}"),
                        URLWithoutProtocol(
                            message="Enter a website address in the correct format, like www.gov.uk",
                            require_tld=True,
                        ),
                    ],
                    filters=[lambda x: x.strip() if x else x],
                )
            case QuestionDataType.CHECKBOXES:
                assert question.data_source is not None
                choices = [(item.key, item.label) for item in question.data_source.items]
                validators: list[Callable[[Any, Any], None]] = [DataRequired(f"Select {question.name}")]
                if question.separate_option_if_no_items_match:
                    # This is a fallback validator in case JS is disabled, to prevent the user selecting both the
                    # separated final checkbox option and another checkbox
                    validators.append(FinalOptionExclusive(question_name=question.name))

                field = SelectMultipleField(
                    label=interpolate(text=question.text, context=interpolation_context),
                    description=interpolate(text=question.hint, context=interpolation_context),
                    widget=MHCLGCheckboxesInput(
                        insert_divider_before_last_item=bool(question.separate_option_if_no_items_match)
                    ),
                    choices=choices,
                    validators=validators,
                )

            case QuestionDataType.DATE:
                field = DateField(
                    label=interpolate(text=question.text, context=interpolation_context),
                    description=interpolate(text=question.hint, context=interpolation_context),
                    widget=GovDateInput() if not question.approximate_date else MHCLGApproximateDateInput(),
                    validators=[DataRequired(f"Enter the {question.name}")],
                    format=["%d %m %Y", "%d %b %Y", "%d %B %Y"]
                    if not question.approximate_date
                    else ["%m %Y", "%b %Y", "%B %Y"],  # multiple formats to help user input
                )

            case QuestionDataType.FILE_UPLOAD:
                assert question.file_types_supported is not None
                assert question.maximum_file_size is not None
                # todo: swap out using the evaluation context to checking on the submission helper for an
                #       answer specifically. The evaluation context is built by the form runner with the
                #       add another context in mind so should be accurate but as this isn't the intended use
                #       there's nothing stopping this functionality from drifting unexpectedly
                has_existing_file = evaluation_context.get(question.safe_qid) is not None
                field = FileField(
                    label=interpolate(text=question.text, context=interpolation_context),
                    description=interpolate(text=question.hint, context=interpolation_context),
                    widget=GovFileInput(),
                    validators=[Optional()]
                    if has_existing_file
                    else [
                        FileRequired(f"Select the {question.name}"),
                        FileAllowed(
                            [
                                extension.strip(".")
                                for ft in question.file_types_supported
                                for extension in ft.extensions
                            ],
                            message="The selected file type is not supported.",
                        ),
                        FileSize(
                            max_size=question.maximum_file_size.max_bytes,
                            message=(
                                f"The selected file must be smaller than {question.maximum_file_size.human_readable}."
                            ),
                        ),
                    ],
                )

            case _:
                raise Exception("Unable to generate dynamic form for question type {_}")

        _DynamicQuestionForm.attach_field(question, field)

    return _DynamicQuestionForm


class CheckYourAnswersForm(FlaskForm):
    section_completed = RadioField(
        "Have you completed this section?",
        choices=[("yes", "Yes, I’ve completed this section"), ("no", "No, I’ll come back to it later")],
        widget=GovRadioInput(),
    )
    submit = SubmitField("Save and continue", widget=GovSubmitInput())

    def __init__(self, *args: Any, all_questions_answered: bool, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)

        # the form should be validly optional unless all questions in the section have been answered
        if all_questions_answered:
            self.section_completed.validators = [DataRequired("Select if you have completed this section")]
        else:
            self.section_completed.validators = [Optional()]


class AddAnotherSummaryForm(FlaskForm):
    def __init__(self, *args: Any, add_another_required: bool = False, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)
        if add_another_required:
            self.add_another.validators = [DataRequired("Select yes if you need to add another answer")]

    add_another = RadioField(
        "Do you need to add another answer?",
        choices=[("yes", "Yes"), ("no", "No")],
        widget=GovRadioInput(),
        validators=[Optional()],
    )
    submit = SubmitField("Continue", widget=GovSubmitInput())


class ConfirmRemoveAddAnotherForm(FlaskForm):
    confirm_remove = RadioField(
        "Are you sure you want to remove this answer?",
        choices=[("yes", "Yes"), ("no", "No")],
        widget=GovRadioInput(),
        validators=[DataRequired("Select yes if you want to remove this answer")],
    )
    submit = SubmitField("Save and continue", widget=GovSubmitInput())


class ConfirmClearQuestionAnswerForm(FlaskForm):
    confirm_remove = RadioField(
        "Are you sure you want to remove your file?",
        choices=[("yes", "Yes"), ("no", "No")],
        widget=GovRadioInput(),
        validators=[DataRequired("Select yes if you want to remove your file")],
    )
    submit = SubmitField("Save and continue", widget=GovSubmitInput())
