#!/usr/bin/env python3
"""
Validate a bunch of stuff.

This is mostly taken from cdedb2 with some adjustments, but the validation
code in that repository has been refactored multiple times since then, so
some details, like exception handling, execution flow and return types are
no longer analog to each other.

Any issues in here should still be reported to the cdedb2 devs, since they
are likely somewhat familiar with what this is trying to do.
"""

import collections.abc
import decimal
import string
from collections.abc import Callable, Iterable, Mapping
from typing import Any


def asciificator(s: str) -> str:
    """Pacify a string.

    Replace or omit all characters outside a known good set. This is to
    be used if your use case does not tolerate any fancy characters
    (like SEPA files).
    """
    umlaut_map = {
        "ä": "ae", "æ": "ae",
        "Ä": "AE", "Æ": "AE",
        "ö": "oe", "ø": "oe", "œ": "oe",
        "Ö": "Oe", "Ø": "Oe", "Œ": "Oe",
        "ü": "ue",
        "Ü": "Ue",
        "ß": "ss",
        "à": "a", "á": "a", "â": "a", "ã": "a", "å": "a", "ą": "a",
        "À": "A", "Á": "A", "Â": "A", "Ã": "A", "Å": "A", "Ą": "A",
        "ç": "c", "č": "c", "ć": "c",
        "Ç": "C", "Č": "C", "Ć": "C",
        "è": "e", "é": "e", "ê": "e", "ë": "e", "ę": "e",
        "È": "E", "É": "E", "Ê": "E", "Ë": "E", "Ę": "E",
        "ì": "i", "í": "i", "î": "i", "ï": "i",
        "Ì": "I", "Í": "I", "Î": "I", "Ï": "I",
        "ł": "l",
        "Ł": "L",
        "ñ": "n", "ń": "n",
        "Ñ": "N", "Ń": "N",
        "ò": "o", "ó": "o", "ô": "o", "õ": "o", "ő": "o",
        "Ò": "O", "Ó": "O", "Ô": "O", "Õ": "O", "Ő": "O",
        "ù": "u", "ú": "u", "û": "u", "ű": "u",
        "Ù": "U", "Ú": "U", "Û": "U", "Ű": "U",
        "ý": "y", "ÿ": "y",
        "Ý": "Y", "Ÿ": "Y",
        "ź": "z",
        "Ź": "Z",
    }  # fmt: skip
    ret = ""
    for char in s:
        if char in umlaut_map:
            ret += umlaut_map[char]
        elif char in (string.ascii_letters + string.digits + " /-?:().,+"):
            ret += char
        else:
            ret += " "
    return ret


def _mapping[T: Mapping](val: T, argname: str | None = None) -> T:
    if not isinstance(val, collections.abc.Mapping):
        raise TypeError("Must be a mapping.")
    return val


def _iterable[T: Iterable](val: T, argname: str | None = None) -> T:
    if not isinstance(val, collections.abc.Iterable):
        raise TypeError("Must be an iterable.")
    return val


def _str(val: Any, argname: str | None = None) -> str:
    if val is not None:
        try:
            val = str(val)
        except (ValueError, TypeError):
            raise ValueError("Invalid input for string.")
    if not val:
        raise ValueError("Must not be empty.")
    return val


def _decimal(val: Any, argname: str | None = None) -> decimal.Decimal:
    try:
        val = decimal.Decimal(val)
    except (ValueError, TypeError, decimal.InvalidOperation):
        raise ValueError("Invalid input for decimal.")
    return val


def _examine_dictionary_fields(
    adict: Mapping,
    mandatory_fields: Mapping[str, Callable[..., Any]],
    optional_fields: Mapping[str, Callable] | None = None,
    argname: str | None = None,
) -> tuple[dict[str, Any], list[Exception]]:
    optional_fields = optional_fields or {}
    prefix = (argname + "_") if argname else ""
    errs: list[Exception] = []
    retval = {}
    mandatory_fields_found = set()
    for key, value in adict.items():
        argname = prefix + key
        if key in mandatory_fields:
            try:
                v = mandatory_fields[key](value, argname=argname)
            except (ValueError, TypeError) as e:
                errs.append(e.__class__(argname, *e.args))
                continue
            mandatory_fields_found.add(key)
            retval[key] = v
        elif key in optional_fields:
            try:
                v = optional_fields[key](value, argname=argname)
            except (ValueError, TypeError) as e:
                errs.append(e.__class__(argname, *e.args))
                continue
            retval[key] = v
        else:
            errs.append(KeyError(argname, f"Superfluous key found ({key})."))
    if len(mandatory_fields) != len(mandatory_fields_found):
        missing = set(mandatory_fields) - mandatory_fields_found
        for key in missing:
            argname = prefix + key
            errs.append(KeyError(argname, f"Mandatory key missing ({key})."))
    return retval, errs


_IBAN_LENGTHS = {
    "DE": 22, "AT": 20, "BE": 16, "BG": 22, "HR": 21, "CY": 28, "CZ": 24,
    "DK": 18, "EE": 20, "FO": 18, "FI": 18, "FR": 27, "GI": 23, "GR": 27,
    "GL": 18, "HU": 28, "IS": 26, "IE": 22, "IT": 27, "LV": 21, "LI": 21,
    "LT": 20, "LU": 20, "MT": 31, "MC": 27, "NL": 18, "NO": 15, "PL": 28,
    "PT": 25, "RO": 24, "SM": 27, "SK": 24, "SI": 19, "ES": 24, "SE": 24,
    "CH": 21, "GB": 22,
}  # fmt: skip


def _get_iban_length(country_code):
    return _IBAN_LENGTHS.get(country_code)


def _iban(val: Any, argname: str | None = None) -> str:
    """
    Validate IBAN.
    """
    argname = argname or "iban"
    val = _str(val, argname)
    val = val.upper().replace(" ", "")
    if len(val) < 5:
        raise ValueError("Too short.")
    for char in val[:2]:
        if char not in string.ascii_uppercase:
            raise ValueError("Must start with country code.")
    for char in val[2:4]:
        if char not in string.digits:
            raise ValueError("Must have digits for checksum.")
    for char in val[4:]:
        if char not in string.digits + string.ascii_uppercase:
            raise ValueError(f"Invalid character in IBAN: {char!r}.")
    if val[:2] not in _IBAN_LENGTHS:
        raise ValueError("Unknown or unsupported Country Code.")
    expected_length = _get_iban_length(val[:2])
    if not len(val) == expected_length:
        raise ValueError(f"Invalid length {len(val)} for country code {val[:2]}. Expected length {expected_length}.")
    else:
        # For calculation of checksum, countrycode and checksum are appended to the end.
        temp = val[4:] + val[:4]
        # Every digit is worth its value. "A" is worth 10, "Z" is worth 35. Characters expand the resulting string.
        temp = "".join(c if c in string.digits else str(ord(c) - 55) for c in temp)
        # The entire thing must now be a decimal integer with a remainder of 1 when dividing by 97.
        if int(temp) % 97 != 1:
            raise ValueError("Invalid checksum.")
    return val


_SEPA_TRANSACTIONS_FIELDS = {
    "unique_id": _str,
    "amount": _decimal,
    "account_owner": _str,
    "iban": _iban,
    "reference": _str,
}

_SEPA_TRANSACTIONS_LIMITS = {
    "unique_id": 35,
    "account_owner": 70,
    "reference": 140,
}


def _transaction(val: Any, argname: str | None = None) -> tuple[dict[str, Any], list[Exception]]:
    """
    Validate transaction.
    """
    argname = argname or "transaction"
    val = _mapping(val, argname)
    mandatory_fields = _SEPA_TRANSACTIONS_FIELDS
    val, errs = _examine_dictionary_fields(val, mandatory_fields, argname=argname)
    if errs:
        return val, errs
    for attribute, validator in _SEPA_TRANSACTIONS_FIELDS.items():
        if validator == _str:
            val[attribute] = asciificator(val[attribute])
        if attribute in _SEPA_TRANSACTIONS_LIMITS:
            max_len = _SEPA_TRANSACTIONS_LIMITS[attribute]
            if len(val[attribute]) > max_len:
                val[attribute] = val[attribute][:max_len]
    return val, []


def check_transactions(transactions: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[Exception]]:
    """
    Return a list of successfully validated transactions and a collection of errors encountered.
    """
    transactions = _iterable(transactions, "transactions")
    errs: list[Exception] = []
    ret = []
    for i, t in enumerate(transactions):
        t, e = _transaction(t, f"transaction_{i}")
        if e:
            errs.extend(e)
            continue
        ret.append(t)
    return ret, errs


def print_error(error: Exception):
    if len(error.args) == 1:
        return str(error)
    if len(error.args) == 2:
        return f"{error.args[0]}: {error.args[1]}"
