from __future__ import annotations
import humanize
import importlib
import phonenumbers
from babel.dates import format_date
from cgi import FieldStorage
from datetime import date
from datetime import datetime
from decimal import Decimal
from dateutil.relativedelta import relativedelta
from mimetypes import types_map
from onegov.form import _
from onegov.form.errors import (DuplicateLabelError, InvalidIndentSyntax,
                                EmptyFieldsetError)
from onegov.form.errors import FieldCompileError
from onegov.form.errors import InvalidFormSyntax
from onegov.form.errors import MixedTypeError
from onegov.form.types import BaseFormT, FieldT
from stdnum.exceptions import (
    ValidationError as StdnumValidationError)
from wtforms import DateField, DateTimeLocalField, RadioField, TimeField
from wtforms.fields import SelectField
from wtforms.validators import DataRequired
from wtforms.validators import InputRequired
from wtforms.validators import Optional
from wtforms.validators import StopValidation
from wtforms.validators import ValidationError
from typing import Generic, TYPE_CHECKING
if TYPE_CHECKING:
    from collections.abc import Collection, Sequence
    from onegov.core.orm import Base
    from onegov.form import Form
    from onegov.form.types import BaseValidator, FieldCondition
    from wtforms import Field
    from wtforms.form import BaseForm
# HACK: We extend the default type map with additional entries for file endings
#       that sometimes don't have a single agreed upon mimetype, we may need
#       to do something more clever in the future and map single file endings
#       to multiple mime types.
types_map.setdefault('.mp3', 'audio/mpeg')
[docs]
class If(Generic[BaseFormT, FieldT]):
    """ Wraps a single validator or a list of validators, which will
    only be executed if the supplied condition callback returns `True`.
    """
    def __init__(
        self,
        condition: FieldCondition[BaseFormT, FieldT],
        *validators: BaseValidator[BaseFormT, FieldT]
    ):
        assert len(validators) > 0, 'Need to supply at least one validator'
[docs]
        self.condition = condition 
[docs]
        self.validators = validators 
[docs]
    def __call__(self, form: BaseFormT, field: FieldT) -> None:
        if not self.condition(form, field):
            return
        for validator in self.validators:
            validator(form, field) 
 
[docs]
class Stdnum:
    """ Validates a string using any python-stdnum format.
    See `<https://github.com/arthurdejong/python-stdnum>`_.
    """
    def __init__(self, format: str):
        module = '.'.join(p for p in format.split('.') if p)
[docs]
    def __call__(self, form: Form, field: Field) -> None:
        # only do a check for filled out values, to check for the existance
        # of any value use DataRequired!
        if not field.data:
            return
        try:
            self.format.validate(field.data)
        except StdnumValidationError as exception:
            raise ValidationError(
                field.gettext('Invalid input.')
            ) from exception 
 
[docs]
class FileSizeLimit:
    """ Makes sure an uploaded file is not bigger than the given number of
    bytes.
    Expects an :class:`onegov.form.fields.UploadField` or
    :class:`onegov.form.fields.UploadMultipleField` instance.
    """
[docs]
    message = _(
        'The file is too large, please provide a file smaller than {}.'
    ) 
    def __init__(self, max_bytes: int):
[docs]
        self.max_bytes = max_bytes 
[docs]
    def __call__(self, form: Form, field: Field) -> None:
        if not field.data:
            return
        if field.data.get('size', 0) > self.max_bytes:
            message = field.gettext(self.message).format(
                humanize.naturalsize(self.max_bytes)
            )
            raise ValidationError(message) 
 
[docs]
class WhitelistedMimeType:
    """ Makes sure an uploaded file is in a whitelist of allowed mimetypes.
    Expects an :class:`onegov.form.fields.UploadField` or
    :class:`onegov.form.fields.UploadMultipleField` instance.
    """
[docs]
    whitelist: Collection[str] = {
        'application/excel',
        'application/vnd.ms-excel',
        'application/msword',
        'application/pdf',
        'application/zip',
        'image/gif',
        'image/jpeg',
        'image/png',
        'image/x-ms-bmp',
        'text/plain',
        'text/csv'
    } 
[docs]
    message = _('Files of this type are not supported.') 
    def __init__(self, whitelist: Collection[str] | None = None):
        if whitelist is not None:
            self.whitelist = whitelist
[docs]
    def __call__(self, form: Form, field: Field) -> None:
        if not field.data:
            return
        if field.data['mimetype'] not in self.whitelist:
            raise ValidationError(field.gettext(self.message)) 
 
[docs]
class ExpectedExtensions(WhitelistedMimeType):
    """ Makes sure an uploaded file has one of the expected extensions. Since
    extensions are not something we can count on we look up the mimetype of
    the extension and use that to check.
    Expects an :class:`onegov.form.fields.UploadField` instance.
    Usage::
        ExpectedExtensions(['*'])  # default whitelist
        ExpectedExtensions(['pdf'])  # makes sure the given file is a pdf
    """
    def __init__(self, extensions: Sequence[str]):
        # normalize extensions
        if len(extensions) == 1 and extensions[0] == '*':
            mimetypes = None
        else:
            mimetypes = {
                mimetype for ext in extensions
                # we silently discard any extensions we don't know for now
                if (mimetype := types_map.get('.' + ext.lstrip('.'), None))
            }
        super().__init__(whitelist=mimetypes) 
[docs]
class ValidSurveyDefinition(ValidFormDefinition):
    """ Makes sure the given text is a valid onegov.form definition for
    surveys.
    """
    def __init__(self, require_email_field: bool = False):
        super().__init__(require_email_field)
[docs]
    invalid_field_type = _("Invalid field type for field '${label}'. Please "
                           "use the plus-icon to add allowed field types.") 
[docs]
    def __call__(self, form: Form, field: Field) -> Form | None:
        from onegov.form.fields import UploadField
        parsed_form = super().__call__(form, field)
        if parsed_form is None:
            return None
        # Exclude fields that are not allowed in surveys
        errors = None
        for field in parsed_form._fields.values():
            if isinstance(field, (UploadField, DateField, TimeField,
                                  DateTimeLocalField)):
                error = field.gettext(self.invalid_field_type %
                                      {'label': field.label.text})
                errors = form['definition'].errors
                if not isinstance(errors, list):
                    errors = form['definition'].process_errors
                    assert isinstance(errors, list)
                errors.append(error)
        if errors:
            raise ValidationError()
        return parsed_form 
 
[docs]
class LaxDataRequired(DataRequired):
    """ A copy of wtform's DataRequired validator, but with a more lax approach
    to required validation checking. It accepts some specific falsy values,
    such as numeric falsy values, that would otherwise fail DataRequired.
    This is necessary in order for us to validate stored submissions, which
    get validated after the initial submission in order to avoid losing file
    uploads.
    """
[docs]
    def __call__(self, form: BaseForm, field: Field) -> None:
        if field.data is False:
            # guard against False, False is an instance of int, since
            # bool derives from int, so we need to check this first
            pass
        elif isinstance(field.data, (int, float, Decimal)):
            # we just accept any numeric data regardless of amount
            return
        # fall back to wtform's validator
        super().__call__(form, field) 
 
[docs]
class StrictOptional(Optional):
    """ A copy of wtform's Optional validator, but with a more strict approach
    to optional validation checking.
    See https://github.com/wtforms/wtforms/issues/350
    """
[docs]
    def is_missing(self, value: object) -> bool:
        if isinstance(value, FieldStorage):
            return False
        if not value:
            return True
        if isinstance(value, str):
            return not self.string_check(value)
        return False 
[docs]
    def __call__(self, form: BaseForm, field: Field) -> None:
        raw = field.raw_data and field.raw_data[0]
        val = field.data
        # the selectfields have this annyoing habit of coercing all values
        # that are added to them -> this includes the None, which is turned
        # into 'None'
        if isinstance(field, SelectField) and val == 'None':
            val = None
        if self.is_missing(raw) and self.is_missing(val):
            field.errors = []
            raise StopValidation() 
 
[docs]
class ValidPhoneNumber:
    """ Makes sure the given input is valid phone number.
    Expects an :class:`wtforms.StringField` instance.
    """
[docs]
    message = _('Not a valid phone number.') 
    def __init__(
        self,
        country: str = 'CH',
        country_whitelist: Collection[str] | None = None
    ):
        if country_whitelist:
            assert country in country_whitelist
[docs]
        self.country_whitelist = country_whitelist 
[docs]
    def __call__(self, form: Form, field: Field) -> None:
        if not field.data:
            return
        try:
            number = phonenumbers.parse(field.data, self.country)
        except Exception as exception:
            raise ValidationError(self.message) from exception
        if self.country_whitelist:
            region = phonenumbers.region_code_for_number(number)
            if region not in self.country_whitelist:
                # FIXME: Better error message?
                raise ValidationError(self.message)
        if not (
            phonenumbers.is_valid_number(number)
            and phonenumbers.is_possible_number(number)
        ):
            raise ValidationError(self.message) 
 
[docs]
class ValidSwissSocialSecurityNumber:
    """ Makes sure the given input is a valid swiss social security number.
    Expects an :class:`wtforms.StringField` instance.
    """
[docs]
    message = _('Not a valid swiss social security number.') 
    def __init__(self) -> None:
[docs]
        self.stdnum_validator = Stdnum(format='ch.ssn') 
[docs]
    def __call__(self, form: Form, field: Field) -> None:
        if not field.data:
            return
        try:
            self.stdnum_validator(form, field)
        except ValidationError:
            raise ValidationError(self.message) from None 
 
[docs]
class UniqueColumnValue:
    """ Test if the given table does not already have a value in the column
    (identified by the field name).
    If the form provides a model with such an attribute, we allow this
    value, too.
    Usage::
        username = StringField(validators=[UniqueColumnValue(User)])
    """
    def __init__(self, table: type[Base]):
[docs]
    def __call__(self, form: Form, field: Field) -> None:
        if field.name not in self.table.__table__.columns:  # type:ignore
            raise RuntimeError('The field name must match a column!')
        if hasattr(form, 'model'):
            if hasattr(form.model, field.name):
                if getattr(form.model, field.name) == field.data:
                    return
        column = getattr(self.table, field.name)
        query = form.request.session.query(column)
        query = query.filter(column == field.data)
        if query.first():
            raise ValidationError(_('This value already exists.')) 
 
[docs]
class ValidDateRange:
    """
    Makes sure the selected date is in a valid range.
    The default error message can be overriden and be parametrized
    with ``min_date`` and ``max_date`` if both are supplied or just
    with ``date`` if only one of them is specified.
    """
[docs]
    between_message = _('Needs to be between {min_date} and {max_date}.') 
[docs]
    after_message = _('Needs to be on or after {date}.') 
[docs]
    before_message = _('Needs to be on or before {date}.') 
    def __init__(
        self,
        min: date | relativedelta | None = None,
        max: date | relativedelta | None = None,
        message: str | None = None
    ):
        if message is not None:
            self.message = message
        elif min is None:
            assert max is not None, 'Need to supply either min or max'
            self.message = self.before_message
        elif max is None:
            self.message = self.after_message
        else:
            self.message = self.between_message
    @property
[docs]
    def min_date(self) -> date | None:
        if isinstance(self.min, relativedelta):
            return date.today() + self.min
        return self.min 
    @property
[docs]
    def max_date(self) -> date | None:
        if isinstance(self.max, relativedelta):
            return date.today() + self.max
        return self.max 
[docs]
    def __call__(self, form: Form, field: Field) -> None:
        if field.data is None:
            return
        value = field.data
        if isinstance(value, datetime):
            value = value.date()
        assert isinstance(value, date)
        if hasattr(form, 'request'):
            locale = form.request.locale
        else:
            locale = 'de_CH'
        min_date = self.min_date
        max_date = self.max_date
        if min_date is not None and max_date is not None:
            # FIXME: To be properly I18n just like with `Layout.format_date`
            #        the date format should depend on the locale.
            if not (min_date <= value <= max_date):
                min_str = format_date(
                    min_date, format='dd.MM.yyyy', locale=locale)
                max_str = format_date(
                    max_date, format='dd.MM.yyyy', locale=locale)
                raise ValidationError(field.gettext(self.message).format(
                    min_date=min_str, max_date=max_str
                ))
        elif min_date is not None and value < min_date:
            min_str = format_date(min_date, format='dd.MM.yyyy', locale=locale)
            raise ValidationError(
                field.gettext(self.message).format(date=min_str)
            )
        elif max_date is not None and value > max_date:
            max_str = format_date(max_date, format='dd.MM.yyyy', locale=locale)
            raise ValidationError(
                field.gettext(self.message).format(date=max_str)
            )