import json
import logging
from collections import OrderedDict
from datetime import datetime
from typing import Optional, Union
from urllib.parse import urlparse

from ..exceptions import NdffLibError
from ..utils import (
    ellipsize2string,
    is_empty,
    is_numeric,
    is_uri,
)

log = logging.getLogger(__name__)


class NdffObject:
    """
    A NdffObject is the base class for NDFF classes handled by the NDFF api.

    IMPORTANT: all NdffObject and derivatives are more or less dumb objects!
    That is: they DO have restrictions on them (like mandatory fields, or that certain fields have to be uri's etc etc),
    but that is NOT checked when creating the objects! This makes it possible to create non-valid objects, so client
    applications can first create such NdffObjects and the use 'is_valid' to get informed about errors in the fields.

    """

    NUMERIC_NON_ZERO_OR_URI_FIELDS = ()
    URI_FIELDS = ()
    OTHER_FIELDS = ()
    OPTIONAL_FIELDS = ()
    NONE_LIKE = (None, "", " ", "-", "?")

    # location is here to make code checker happy, AND because we want to have is_valid at this level
    # a location should always be a valid location dict, never something else!
    # NOTE: the creation of a valid location dict is the responsibility of the NdffConnector object!
    location: Optional[dict] = None

    def fields(self) -> dict:
        """
        Return all fields of current Ndff Object as a dict
        :return:
        """
        return self.__dict__

    def get(self, field_name):
        """
        Return the value of a field by its name, or None if the field is not available
        :param field_name:
        :return: value of the field with name field_name or None if the field is not found
        """
        if field_name in self.__dict__:
            if field_name == "location":
                if self.location:
                    if (
                        isinstance(self.location, (dict, OrderedDict))
                        and "geometry" in self.location
                    ):
                        self.location = OrderedDict(self.location)
                        # IF there is a geometry (and buffer) in the location, move the geometry (key) to the end, so in
                        # an abbreviated location we at least still see the buffer value
                        self.location.move_to_end("geometry")
                        # move it to a normal dict again (because: better to_str)
                        self.location = dict(self.location)
                    # now only return the first x chars of it if the string representation of it is longer then max_len
                    return ellipsize2string(self.location, max_len=120)
                else:
                    return "Location ??"
            else:
                return self.__dict__[field_name]
        else:
            return None

    def set(self, field_name: str, field_value) -> None:
        """
        dict value setter with conditions
        :param str field_name: name of the field to set
        :param field_value: value of the field
        :raise: a ValueError in case the object does not have the field with this name
        :return:
        """
        # TODO more checks?
        if field_name in self.__dict__:
            # special case of location where we ONLY want the value to be a dict (actually geojson object tree)
            if field_name == "location" and not isinstance(field_value, dict):
                raise ValueError(
                    "Not possible to set location for an NdffObject: value is NOT a (location) dict!"
                )
            self.__dict__[field_name] = field_value
        else:
            raise ValueError(
                f'Not possible to set "{field_name}" for this NdffObject: NOT an available property!'
            )

    def is_valid_location(self, errors: list, field_mapping: dict = None):
        """
        This method checks if the object has a valid location, and appends error messages if not to the errors argument
        """
        none_message = 'Verplicht veld "{}" is nog leeg of onbekend'
        not_dict_message = '"Locatie" zou een object moeten zijn, maar is dat niet'
        missing_location_field = '"Locatie" zou een veld "{}" (waarde) moeten hebben, of de waarde is niet geldig'
        wrong_location_buffer_type = 'De buffer/nauwkeurigheid (van Locatie) moet een numerieke waarde zijn (meters), maar heeft de waarde "{}"'
        wrong_geometry = (
            "Er is iets misgegaan bij het maken van de NDFF geometry, check data"
        )
        wrong_point_buffer = 'De buffer/nauwkeurigheid (van Locatie) moet een getal zijn, en groter zijn dan 0 voor (multi)points/lijnen, maar heeft waarde "{}"'
        wrong_polygon_buffer = 'De buffer/nauwkeurigheid (van Locatie) moet een getal zijn, en voor (multi)polygonen altijd 0 zijn, maar heeft waarde "{}"'
        if field_mapping is None:
            field_mapping = {}

        # Location related fields:
        # "location": {
        #     "buffer": 5,
        #     "geometry": {
        #         "type": "Point",
        #         "coordinates": [
        #             408241,
        #             78648
        #             ]
        #         }
        #     },
        if self.location in self.NONE_LIKE:
            errors.append(none_message.format("location"))
        elif not isinstance(self.location, dict):
            errors.append(not_dict_message.format("location"))
        else:
            # ok, we have a dict, check keys
            # check for buffer and geometry
            if "buffer" not in self.location:
                errors.append(
                    missing_location_field.format("buffer/Locatie-nauwkeurigheid")
                )
            if "geometry" not in self.location:
                errors.append(missing_location_field.format("geometry"))
            # check for valid buffer value
            if "buffer" in self.location and self.location["buffer"] in (
                "",
                None,
                "-",
                "?",
            ):
                if none_message.format("location_buffer") not in errors:
                    errors.append(none_message.format("location_buffer"))
            elif "buffer" in self.location and not is_numeric(self.location["buffer"]):
                errors.append(
                    wrong_location_buffer_type.format(self.location["buffer"])
                )
            # further geometry checking...
            if (
                "geometry" in self.location
                and "coordinates" in self.location["geometry"]
                and "type" in self.location["geometry"]
                and self.location["geometry"]["type"].upper()
                in ("POINT", "POLYGON", "MULTIPOLYGON", "LINESTRING", "MULTILINESTRING")
            ):
                # going to check the value of the buffer:
                if "buffer" in self.location and self.location["geometry"][
                    "type"
                ].upper() in (
                    "POINT",
                    "LINESTRING",
                    "MULTILINESTRING",
                ):
                    # a (MULTI)POINT and (MULTI)LINESTRINGS should ALWAYS have a NON-ZERO buffer
                    if (
                        not is_numeric(self.location["buffer"])
                        or float(self.location["buffer"]) <= 0
                    ):
                        errors.append(
                            wrong_point_buffer.format(self.location["buffer"])
                        )
                elif "buffer" in self.location and self.location["geometry"][
                    "type"
                ].upper() in ("POLYGON", "MULTIPOLYGON"):
                    # while a (MULTI)POLYGON should always have a ZERO buffer
                    if (
                        not is_numeric(self.location["buffer"])
                        or float(self.location["buffer"]) > 0
                    ):
                        errors.append(
                            wrong_polygon_buffer.format(self.location["buffer"])
                        )
                else:
                    pass
            else:
                errors.append(wrong_geometry)

    def __is_valid_period(
        self, errors: list, field_mapping: dict = None, future_end_ok: bool = False
    ):
        none_message = 'Verplicht veld "{}" is nog leeg of onbekend'
        period_wrong_order = 'De start van de periode: "{}", ligt niet VOOR of OP het einde van de periode: "{}"'
        period_in_future = 'De periode "{}" - "{}" ligt in de toekomst'
        if field_mapping is None:
            field_mapping = {}

        # period (period_start and period_stop)
        # NDFF 400 message: "Datetime heeft een ongeldig formaat, gebruik 1 van de volgende formaten:
        # YYYY-MM-DDThh:mm:ss, YYYY-MM-DD hh:mm:ss, YYYY-MM-DDThh:mm, YYYY-MM-DD hh:mm, YYYY-MM-DDThh, YYYY-MM-DD hh, YYYY-MM-DD, YYYY-MM, YYYY

        # append errors (IF any) after validation of the periods
        start, period_start_errors = self.is_valid_datetime_string(
            "period_start", field_mapping
        )
        if start is None or period_start_errors:
            errors += period_start_errors  # using += here and NOT .append because the period_start_errors returns a list, so JOINing instead of appending
        stop, period_stop_errors = self.is_valid_datetime_string(
            "period_stop", field_mapping
        )
        if stop is None or period_stop_errors:
            errors += period_stop_errors  # using += here and NOT .append because the period_start_errors returns a list, so JOINing instead of appending

        if self.period_start is None:
            errors.append(
                self._format_error(field_mapping, none_message, "period_start")
            )
        if self.period_stop is None:
            errors.append(
                self._format_error(field_mapping, none_message, "period_stop")
            )

        # NOT going to check if stop > start, as NDFF also accepts start == stop :-(
        if start and stop:
            if not future_end_ok and stop > datetime.now():
                errors.append(
                    period_in_future.format(self.period_start, self.period_stop)
                )
            if start > stop:
                errors.append(
                    period_wrong_order.format(self.period_start, self.period_stop)
                )

    def __is_valid_extra_info(self, errors: list):
        for kv in self.extra_info:
            isvalid, error_list = self.is_valid_extra_info(kv["key"], kv["value"])
            if not isvalid:
                errors += error_list

    @staticmethod
    def _format_error(
        mappings=None, format_string=None, format_field=None, sub1=None, sub2=None
    ) -> str:
        """
        This method will, given a set of (client-mappings) and a format_string (f-string) and the values to be
        used (format_field, sub1, sub2) substitute all {} in the f-string.
        Method is created to be able to return 'client'-names in the error messages (KT-355)
        """
        if mappings is None:
            mappings = {}

        if format_field in mappings.keys():
            format_field = mappings[format_field][1]

        if sub2 is None:
            return format_string.format(format_field, sub1)
        elif sub1 is None:
            return format_string.format(format_field)
        else:
            return format_string.format(format_field, sub1, sub2)

    def is_valid(self, field_mapping: dict = None) -> (bool, list):
        """
        Method to do a (fast) local check of the validity of the NDFF Object.

        Mostly if certain mandatory fields have a value, if the type of a field is an uri if needed,
        if a date string is OK and if a period is a valid period etc.

        Because it is possible for clients to determine/set the shown fieldnames themselves (using client.settings)
        and we want to show THOSE names instead of the internal/actual object members (KT-355)  the 'field_mapping'
        argument is added. That dict is created from the so called client_settings.csv which hold the following fields:
         field, text, url1_woordenboek, url2_default, description, ndff_field
        so the 'text' field / [1] will then be used in error messages

        NOTE: the object is NOT sent to the NDFF to validate! This is a quick local test!
        TODO check on related codes or uri for certain abundance/extrainfo?

        :return: a tuple of which the first item is a boolean (valid) and the second item is a list of all
        errors, or actually error messages
        """
        errors = []
        if field_mapping is None:
            field_mapping = {}
        none_message = 'Verplicht veld "{}" is nog leeg of onbekend'
        not_uri_message = 'Veld "{}" zou een geldige URI moeten zijn: "{}"'
        value_not_zero = 'De waarde voor "{}" moet numeriek en groter dan 0 zijn, OF een URI (bij codes), maar heeft waarde "{}"'

        # URI fields:
        for name in self.URI_FIELDS:
            if self.__dict__[name] in self.NONE_LIKE:
                if (
                    name not in self.OPTIONAL_FIELDS
                ):  # 20220421 dwelling is now an optional URI value
                    errors.append(self._format_error(field_mapping, none_message, name))
            elif not is_uri(self.__dict__[name]):
                # Mmm... except when it is an abundance_value, which can be both uri and literal...
                # idea: check for related codes than uri?
                if name != "abundance_value":
                    errors.append(
                        self._format_error(
                            field_mapping, not_uri_message, name, self.__dict__[name]
                        )
                    )

        # NON uri fields, we can only check for None/Null
        for name in self.OTHER_FIELDS:
            if self.__dict__[name] in self.NONE_LIKE:
                errors.append(self._format_error(field_mapping, none_message, name))

        # NON zero fields:
        for name in self.NUMERIC_NON_ZERO_OR_URI_FIELDS:
            # EITHER numeric, THEN not zero
            # OR a URI
            if is_uri(self.__dict__[name]):
                pass  # OK
            elif is_numeric(self.__dict__[name]):
                # could be OK
                if self.__dict__[name] in (None, "", "0", 0):
                    # ERR: numeric fields should not be zero
                    errors.append(
                        self._format_error(
                            field_mapping, value_not_zero, name, self.__dict__[name]
                        )
                    )
            else:
                # not a URI and not numeric
                errors.append(
                    self._format_error(
                        field_mapping, value_not_zero, name, self.__dict__[name]
                    )
                )

        # ONLY if this ndff object has a 'location' field
        if "location" in self.__dict__:
            self.is_valid_location(errors, field_mapping)
        # only checking for period_start... if it is set, there should also be a period_end
        if "period_start" in self.__dict__:
            if isinstance(self, NdffDataset):
                self.__is_valid_period(errors, field_mapping, future_end_ok=True)
            else:
                self.__is_valid_period(errors, field_mapping)
        # only checking for extra_info has set items (key:uri, value uri/numeric as abundance_value
        if "extra_info" in self.__dict__ and len(self.__dict__["extra_info"]) > 0:
            self.__is_valid_extra_info(errors)

        return len(errors) == 0, errors

    @staticmethod
    def is_valid_extra_info(key_uri, value) -> (bool, list):
        """
        Check validity of extra info key/value

        Both key_uri and value are mandatory
        The Key should ALWAYS be an uri
        The Value can be both a data value or a value mapped to a NDFF uri

        :return: a tuple of a boolean (valid) and a list of error messages
        """
        error_list = []
        not_uri_message = 'Extra info veld zou een geldige URI moeten zijn: "{}"'

        if key_uri is None or not is_uri(key_uri):
            error_list.append(not_uri_message.format(key_uri))
        # jan 2026, KT-459: it's possible a user has records with missing values, so we shouldn't check on empty anymore
        # none_message = 'Extra info veld "{}" is nog leeg of onbekend:"{}"'
        # if value is None or is_empty(value):
        #     error_list.append(none_message.format(key_uri, value))

        return (len(error_list) == 0), error_list

    def set_extra_info(self, key_uri: str, value: Union[str, None]) -> (bool, list):
        """
        Add and ExtraInfo Key/Value pair to this object

        :param str/uri key_uri: a uri used to set extra info field name
        :param str/uri value: : extra info field value, maybe uri but also strings or numbers OR null/empty
        """
        valid, error_list = self.is_valid_extra_info(key_uri, value)
        # jan 2026, KT-459: it's possible a user has records with missing values, then do NOT add the key:
        # if is_empty(value):
        #     log.debug(f"Extra info field {key_uri} is empty, NOT adding it to the extra info list")
        # else:
        #     self.extra_info.append({"key": key_uri, "value": value})
        self.extra_info.append({"key": key_uri, "value": value})
        return valid, error_list

    def is_valid_datetime_string(
        self, datetime_field, field_mapping: dict = None
    ) -> (datetime, list):
        """
        Test if datetime_field is a valid datetime string AND (!) return it in iso format if it is.
        If it is NOT a valid datetime string return a Tuple with None and a list of errors as second item

        Valid as defined by NDFF

        If NOT valid, return None and a list of errors, if VALID: return a valid isodatetimestring


        :param datetime_field:
        :param field_mapping: client settings mapping to be able to do proper error messages
        :return: a tuple with a valid timestring (first item) or a list of errors (second item)
        """
        valid_datetime = None
        if field_mapping is None:
            field_mapping = {}
        wrong_datetime_format = (
            'Veld "{}" met waarde "{}" zou een NDFF-geldig formaat moeten hebben: '
            "YYYY-MM-DDThh:mm:ss, YYYY-MM-DD hh:mm:ss, YYYY-MM-DDThh:mm, YYYY-MM-DD hh:mm, "
            "YYYY-MM-DDThh, YYYY-MM-DD hh, YYYY-MM-DD, YYYY-MM, YYYY"
        )
        wrong_datetime_format2 = (
            'LET OP: in de "{}"-datums mag ook YYYY-MM-DDThh:mm:ss.sss of YYYY-MM-DDThh:mm:ss.ssssss '
            "worden gebruikt, maar de seconden zullen worden afgerond bij het "
            "verzenden naar de NDFF api."
        )

        if self.get(datetime_field) is None:
            return None, [
                self._format_error(
                    field_mapping, wrong_datetime_format, datetime_field, None
                )
            ]  # return list!

        datetime_string = str(
            self.get(datetime_field)
        ).strip()  # strip to be sure to remove leading/trailing spaces
        try:
            # just try to create a datetime from iso format
            valid_datetime = datetime.fromisoformat(datetime_string)
            # ok, that worked, done:
            return valid_datetime, []
        except ValueError:
            pass

        # apparently one of those other possible formats
        datetime_string = datetime_string.replace("T", " ")
        errors = []
        try:
            # match formatstring based on length (as key)
            formats = {
                "4": "%Y",
                "7": "%Y-%m",
                "10": "%Y-%m-%d",
                "13": "%Y-%m-%d %H",
                "16": "%Y-%m-%d %H:%M",
                "19": "%Y-%m-%d %H:%M:%S",
            }
            if str(len(datetime_string)) in formats.keys():
                valid_datetime = datetime.strptime(
                    datetime_string, formats[str(len(datetime_string))]
                )
            else:
                raise NdffLibError("Wrong DATETIME format")
        except ValueError:  # datetime_string not matching in strptime
            errors.append(
                self._format_error(
                    field_mapping,
                    wrong_datetime_format,
                    datetime_field,
                    datetime_string,
                )
            )
            errors.append(
                self._format_error(
                    field_mapping, wrong_datetime_format2, datetime_field
                )
            )
        except NdffLibError:
            errors.append(
                self._format_error(
                    field_mapping,
                    wrong_datetime_format,
                    datetime_field,
                    datetime_string,
                )
            )
            errors.append(
                self._format_error(
                    field_mapping, wrong_datetime_format2, datetime_field
                )
            )
        return valid_datetime, errors


class NdffDatasetType(NdffObject):
    """
    A NdffDatasetType is a NDFF Object holding one of the DatasetType's a NDFF user has

    Examples are 'gewone map', 'prullenbak' etc. So it defines the Type of the Dataset (in NDFF terms)

    A DatasetType is defined by its identity, a category, description and its own identity uri
    """

    def __init__(
        self,
        identity: str = None,
        category: str = None,
        description: str = None,
        ndff_uri: str = None,
    ):
        """
        Constructor of a DatasetType

        :param str identity:
        :param str category:
        :param str description:
        :param str/uri ndff_uri:
        """
        self.identity = identity  # URI
        self.category = category  # any
        self.description = description  # any
        self.ndff_uri = ndff_uri  # URI

    def to_ndff_datasettype_json(self, datasettype_identity=None) -> str:
        """
        Return self as a pretty printed JSON-String(!), for example:

        https://accapi.ndff.nl/api/v2/domains/708/datasettypes/21534/
        {
            "_links": {
                "self": {
                    "href": "https://accapi.ndff.nl/api/v2/domains/708/datasettypes/21534/"
                }
            },
            "category": "gewone map",
            "description": "map Test NGB",
            "identity": "http://ndff.nl/foldertypes/test_ngb/map"
        }

        :param datasettype_identity:
        :return:
        """
        data = {}

        if datasettype_identity:
            data["identity"] = datasettype_identity
        else:
            data["identity"] = self.identity
        data["category"] = self.category
        data["description"] = self.description

        return json.dumps(data, indent=2)


class NdffProtocol(NdffObject):
    """
    A NdffProtocol is a NDFF Object holding one NDFF Protocol, see https://accapi.ndff.nl/codes/v2/protocols/

    For example see: https://accapi.ndff.nl/codes/v2/codes/3827850/

    A NDFF-dataset can have serveral NdffProtocols'
    """

    # all URI_FIELDS that should have a URI as value
    URI_FIELDS = ("identity",)

    OTHER_FIELDS = ("description",)

    def __init__(
        self,
        identity: str = None,
        category: str = None,
        description: str = None,
        ndff_uri: str = None,
    ):
        """
        Constructor of a NdffProtocol

        :param identity:
        :param category:
        :param description:
        :param ndff_uri:
        """
        self.identity = identity  # URI
        self.category = category
        self.description = description  # any
        self.ndff_uri = ndff_uri  # URI

    def to_ndff_protocol_json(self, protocol_identity=None) -> str:
        """
        Return self as a pretty printed JSON-String(!), for example:

        https://accapi.ndff.nl/codes/v2/codes/3827850/
        {
            "_links": {
                "self": {
                    "href": "https://accapi.ndff.nl/codes/v2/codes/3827850/"
                }
            },
            "description": "Deze set van protocollen is gericht op soortinventarisaties die nodig zijn om rondom
            bestendig beheer en onderhoud binnen de kaders van de Flora- en Faunawet 1 te kunnen werken.
            Van uitvoerders van beheerwerkzaamheden verwacht ProRail dat ze volgens de wet werken en gebruik
            maken van de gegevens uit de NDFF voor het raadplegen van actuele gegevens bij het plannen en
            uitvoeren van bestendig beheer. Deze protocollen gelden voor soortkarteringen die in het algemeen
            volgen op een biotoopkartering zoals ook beschreven in hoofdstuk 3 in dit protocol of een habitatscan
             (104.004 Habitatscan ProRail), waaruit de geschiktheid van een gebied voor het voorkomen van soorten
             kan worden afgeleid. Binnen het werkgebied van ProRail beperkt bestendig beheer en onderhoud zich in
             ruimtelijke zin tot regulier beheerde spoorbermen (graslanden, ruigten en struwelen) en spoorbermsloten,
             dijktaluds en oevers. Deze protocollen hebben geen toepassing in bosvegetaties, in grotere wateren, in/op
             gebouwen en in/op kunstwerken. Ruimtelijk zijn deze protocollen beperkt tot gebieden in beheer bij ProRail.
             Om veiligheidsredenen is de gevarenzone langs het spoor uitgesloten van inventarisaties. De harde
             gegevensbehoefte is beperkt tot FF-wetsoorten van tabel 2 en 3. Voor een complete inventarisatie in een
             gebied met een vertegenwoordiging van diverse soortgroepen wordt uitgegaan van drie inventarisatieronden,
             met ieder hun eigen accenten voor wat betreft de te inventariseren groepen.",
            "identity": "http://ndff-ecogrid.nl/codes/protocols/104.003",
            "indexvalue": "ptl.11.12",
            "name": "104.003 Soorten- en biotoopkartering ProRail"
        }
        """
        data = {}

        if protocol_identity:
            data["identity"] = protocol_identity
        else:
            data["identity"] = self.identity
        data["description"] = self.description

        return json.dumps(data, indent=2)


class NdffDataset(NdffObject):
    """
    A NdffDataset is an object defining a (user's) NDFF Dataset
    """

    # all URI_FIELDS that should have a URI as value
    URI_FIELDS = (
        "dataset_type",
        "parent",
        "identity",
        "protocol",
    )

    OTHER_FIELDS = (
        "period_start",
        "period_stop",
        "description",
    )

    OPTIONAL_FIELDS = (
        "duration",
        "location_coverage",
        "protocol",
    )

    # this is the key to be used for the doelsoorten extra info of a dataset:
    SPECIES_LIST_URI = "http://ndff-ecogrid.nl/codes/keys/survey/specieslist"

    def __init__(
        self,
        description: str = None,
        dataset_type_uri: str = None,
        parent_uri: str = None,
    ):
        """
        Constructor to create a NdffDataset with all mandatory elements

        :param description:
        :param dataset_type_uri:
        :param parent_uri:
        """
        self.ndff_uri = None  # URI optional
        self.identity = None  # URI mandatory
        self.parent = parent_uri  # URI mandatory
        self.dataset_type = dataset_type_uri  # URI mandatory
        self.description = description  # any mandatory
        self.duration = 0  # any
        # location: mandatory for now always fixed starting coordinates ??
        self.location = {
            "buffer": 10,
            "geometry": {"type": "Point", "coordinates": [5.38759722, 52.1556583]},
        }
        self.location_coverage = 0  # ?? don't know what this is for?

        self.protocol = None  # URI optional

        self.period_start = None  # mandatory NDFF Date(Time) str: YYYY-MM-DDThh:mm:ss, YYYY-MM-DD hh:mm:ss, YYYY-MM-DDThh:mm, YYYY-MM-DD hh:mm, YYYY-MM-DDThh, YYYY-MM-DD hh, YYYY-MM-DD, YYYY-MM, YYYY
        self.period_stop = None  # mandatory NDFF Date(Time) str: YYYY-MM-DDThh:mm:ss, YYYY-MM-DD hh:mm:ss, YYYY-MM-DDThh:mm, YYYY-MM-DD hh:mm, YYYY-MM-DDThh, YYYY-MM-DD hh, YYYY-MM-DD, YYYY-MM, YYYY

        self.extra_info = []
        self.involved = []

    @staticmethod
    def from_dataset_json_to_dataset(dataset_json_dict: dict):
        """
        Create a NdffDataset instance from a Dict (Python Object tree from json probably)

        {
             '_links': {
                'self': {
                     'href': 'https://accapi.ndff.nl/api/v2/domains/708/datasets/2922579/'
                }
             },
             'datasetType': 'http://ndff.nl/foldertypes/test_ngb/map',
             'description': 'test_richard',
             'duration': None,
             'extrainfo': [],
             'identity': 'http://ndff.nl/testngb/folders/2922579',
             'involved': [],
             'location': None,
             'locationCoverage': None,
             'parent': 'http://ndff.nl/folders/2920720',
             'periodStart': None,
             'periodStop': None,
             'protocol': None
        }

        :return: a NdffDataset instance
        """
        d = NdffDataset()
        d.dataset_type = dataset_json_dict["datasetType"]
        d.description = dataset_json_dict["description"]
        d.duration = dataset_json_dict["duration"]
        d.extra_info = dataset_json_dict["extrainfo"]
        d.identity = dataset_json_dict["identity"]
        d.involved = dataset_json_dict["involved"]
        d.location = dataset_json_dict["location"]
        d.location_coverage = dataset_json_dict["locationCoverage"]
        d.parent = dataset_json_dict["parent"]
        d.period_start = dataset_json_dict["periodStart"]
        d.period_stop = dataset_json_dict["periodStop"]
        d.protocol = dataset_json_dict["protocol"]
        if (
            dataset_json_dict["_links"]
            and dataset_json_dict["_links"]["self"]
            and dataset_json_dict["_links"]["self"]["href"]
        ):
            d.ndff_uri = dataset_json_dict["_links"]["self"]["href"]
        return d

    def to_ndff_dataset_json(self, dataset_identity=None) -> str:
        """
        Return self as a pretty printed JSON-String(!), for example:

        For example: https://accapi.ndff.nl/api/v2/domains/708/datasets/2929738/
        {
            "_links": {
                "self": {
                    "href": "https://accapi.ndff.nl/api/v2/domains/708/datasets/2929738/"
                }
            },
            "datasetType": "http://ndff.nl/foldertypes/test_ngb/map",
            "description": "test_dataset",
            "duration": 0,
            "extrainfo": [],
            "identity": "http://ndff.nl/testngb/folders/2929738",
            "involved": [],
            "location": {
                "buffer": 10,
                "geometry": {
                    "type": "Point",
                    "coordinates": [
                        5.38759722,
                        52.1556583
                    ]
                }
            },
            "locationCoverage": 0,
            "parent": "http://ndff.nl/folders/2920720",
            "periodStart": null,
            "periodStop": null,
            "protocol": null
        }

        :param str dataset_identity: Optional dataset_identity, to be able to override the identity (testing purposes)
        :return: a json string representation
        """
        data = {}

        if dataset_identity:
            data["identity"] = dataset_identity
        else:
            data["identity"] = self.identity
        data["parent"] = self.parent
        data["datasetType"] = self.dataset_type
        data["description"] = self.description
        data["duration"] = self.duration
        data["protocol"] = self.protocol
        # periods could be datetime objects (in case of Postgis Datasource)
        data["periodStart"] = str(self.period_start)
        data["periodStop"] = str(self.period_stop)
        data["location"] = self.location
        data["locationCoverage"] = self.location_coverage
        # note: this is the data holder for the lists in the json (NOT fields from record or observation)
        data["extrainfo"] = self.extra_info
        data["involved"] = self.involved

        return json.dumps(data, indent=2)


class NdffObservation(NdffObject):
    """
    A NdffObservation object is more of a simple data holder, able to validate itself and to serialize itself to json

    A NdffObservation object is created using the NdffConnector's 'map_data_to_ndff_observation'.

    It is the responsibility of the NdffConnector to create a more or less valid NdffObservation.
    """

    # all URI_FIELDS that should have a URI as value
    URI_FIELDS = (
        "taxon",
        "abundance_schema",
        "determination_method",
        "dataset",
        "biotope",
        "identity",
        "lifestage",
        "subject_type",
        "survey_method",
        "sex",
        "activity",
        "dwelling",
        "abundance_value",  # NOTE: this can be both a URI and a literal (mostly integer....)
    )

    OTHER_FIELDS = (
        # 'abundance_value',
        "period_start",
        "period_stop",
    )

    # abundance value is a special case. In case of exact count it should be a number >0, but in case of (Tansley,
    # Londo, etc. codes, it MUST be a URI)
    NUMERIC_NON_ZERO_OR_URI_FIELDS = ("abundance_value",)

    OPTIONAL_FIELDS = ("dwelling",)

    def __init__(self):
        """
        Constructor of an NdffObservation

        By choice, it is possible to create an NdffObservation without any field params. This is to be able to create
        an invalid object which then can be validated later

        """
        self.taxon = None  # URI
        self.abundance_schema = None  # URI
        self.abundance_value = None  # any
        self.activity = None  # URI
        self.determination_method = None  # URI
        self.dataset = None  # URI
        self.biotope = None  # URI
        self.identity = (
            None  # URI, actually the INTERNAL identity (NOT the NDFF identity link)
        )
        self.lifestage = None  # URI
        self.period_start = None  # NDFF Date(Time) str: YYYY-MM-DDThh:mm:ss, YYYY-MM-DD hh:mm:ss, YYYY-MM-DDThh:mm, YYYY-MM-DD hh:mm, YYYY-MM-DDThh, YYYY-MM-DD hh, YYYY-MM-DD, YYYY-MM, YYYY
        self.period_stop = None  # NDFF Date(Time) str: YYYY-MM-DDThh:mm:ss, YYYY-MM-DD hh:mm:ss, YYYY-MM-DDThh:mm, YYYY-MM-DD hh:mm, YYYY-MM-DDThh, YYYY-MM-DD hh, YYYY-MM-DD, YYYY-MM, YYYY
        self.sex = None  # URI
        self.subject_type = None  # URI
        self.survey_method = None  # URI
        self.dwelling = None  # URI OPTIONAL

        self.extra_info = []
        self.involved = []

        # a location should always be a valid location dict, never something else!
        # NOTE: the creation of a valid location dict is the responsibility of the NdffConnector object!
        self.location: dict = Optional[dict]

    def to_ndff_observation_json(
        self, observation_identity=None, observation_dataset=None, as_json_string=True
    ) -> dict | str:
        """
        Default: Create valid JSON-string(!) for NDFF based on current values of all fields.
        But can also be used to create valid dict of this observation, then use
        as_json_string=False.

        The observation_identity and observation_dataset params make it possible
        to override the real identity/dataset content of the observation, thereby
        making it easier to create unique observation json for testing...

        If the param 'observation_identity' is None, the field 'identity'
        should be a valid identity(-uri), else the value of param 'observation_identity'
        will be used.

        If the param 'observation_dataset' is None, the field 'dataset' should
        be a valid dataset(-uri) AND should be available/defined at NDFF, else
        the value of param 'observation_dataset' will be used.

        {"abundanceSchema": "http://ndff-ecogrid.nl/codes/scales/exact_count",
            "abundanceValue": 1,
            "activity": "http://ndff-ecogrid.nl/codes/domainvalues/observation/activities/calling",
            "determinationMethod": "http://ndff-ecogrid.nl/codes/domainvalues/observation/determinationmethods/550",
            "extrainfo": [],
            "dataset": "http://notatio.nl/dataset/2",
            "biotope": "http://ndff-ecogrid.nl/codes/domainvalues/location/biotopes/unknown",
            "identity": "http://notatio.nl/waarneming/7500",
            "involved": [
                {
                "involvementType": "http://ndff-ecogrid.nl/codes/involvementtypes/data_owner",
                "person": "http://telmee.nl/contacts/persons/1261085"
                }
            ],
            "lifestage": "http://ndff-ecogrid.nl/codes/domainvalues/observation/lifestages/509",
            "location": {
                "buffer": 5,
                "geometry": {
                    "type": "Point",
                    "coordinates": [
                        408241,
                        78648
                        ]
                    }
                },
            "periodStart": "2014-08-29 01:22:00",
            "periodStop": "2014-08-29 01:22:00",
            "sex": "http://ndff-ecogrid.nl/codes/domainvalues/observation/sexes/undefined",
            "subjectType": "http://ndff-ecogrid.nl/codes/subjecttypes/live/individual",
            "surveyMethod": "http://ndff-ecogrid.nl/codes/domainvalues/survey/surveymethods/na",
            "taxon": "http://ndff-ecogrid.nl/taxonomy/taxa/pipistrellusnathusii",
            "dwelling": "http://ndff-ecogrid.nl/codes/domainvalues/observation/dwellings/unknown",
            "extrainfo": [
                {
                  "key": "http://ndff-ecogrid.nl/codes/keys/external/location_id",
                  "value": "NL09_G6140002"
                },
                {
                  "key": "http://ndff-ecogrid.nl/codes/keys/external/original_visit_id",
                  "value": "NL09_G6140002_2014_7_21_VB1_1"
                }
            ]

        }

        :param: observation_identity (optional)
        :param: observation_dataset (optional)
        :return: NDFF json as string
        """

        if not self.is_valid()[0]:
            raise ValueError(self.is_valid()[1])

        data = {}

        if observation_identity:
            data["identity"] = observation_identity
        else:
            data["identity"] = self.identity
        if observation_dataset:
            data["dataset"] = observation_dataset
        else:
            data["dataset"] = self.dataset
        data["abundanceSchema"] = self.abundance_schema
        data["abundanceValue"] = self.abundance_value
        data["activity"] = self.activity
        data["determinationMethod"] = self.determination_method
        # dataset ^
        data["biotope"] = self.biotope
        # identity ^
        data["lifestage"] = self.lifestage
        data["sex"] = self.sex
        data["subjectType"] = self.subject_type
        data["surveyMethod"] = self.survey_method
        data["taxon"] = self.taxon
        # periods could be datetime objects (in case of Postgis Datasource), that is the reason we cast to str
        # data['periodStart']             = str(self.period_start)
        # data['periodStop']              = str(self.period_stop)
        # some time strings contain decimal seconds, API does not want that, that is the reason we format as below
        # https://ndff.zendesk.com/hc/nl/requests/41288
        data["periodStart"] = (
            self.is_valid_datetime_string("period_start")[0]
        ).strftime("%Y-%m-%dT%H:%M:%S")
        data["periodStop"] = (self.is_valid_datetime_string("period_stop")[0]).strftime(
            "%Y-%m-%dT%H:%M:%S"
        )
        data["location"] = self.location
        # dwelling is an OPTIONAL key, not to be sent if unknown or empty
        # BUT also the value like: http://ndff-ecogrid.nl/codes/domainvalues/observation/dwellings/unknown
        # or
        #  http://ndff-ecogrid.nl/codes/domainvalues/observation/dwellings/na
        # is NOT going to be sent
        if self.dwelling and str(self.dwelling).lower() not in (
            "none",
            "",
            "-",
            "http://ndff-ecogrid.nl/codes/domainvalues/observation/dwellings/unknown",
            "http://ndff-ecogrid.nl/codes/domainvalues/observation/dwellings/na",
        ):
            # dwelling is not a separate field anymore: # data['dwelling']= self.dwelling
            self.extra_info.append(
                {
                    "key": "http://ndff-ecogrid.nl/codes/keys/observation/dwellings",
                    "value": self.dwelling,
                }
            )

        # note: this is the data holder for the lists in the JSON (NOT fields from record or observation)
        # jan 2026, KT-459: it's possible a user has records with missing/empty values, in that case we should
        # NOT send the extra_info key/value pair to the NDFF
        extra_info = []
        for key_value in self.extra_info:
            if is_empty(key_value["value"]):
                log.debug(
                    f"Ignoring extra info line. Empty extra info value: {key_value['value']} for key: {key_value['key']}."
                )
            else:
                extra_info.append(
                    {"key": key_value["key"], "value": key_value["value"]}
                )
        data["extrainfo"] = extra_info

        data["involved"] = self.involved

        if as_json_string:
            return json.dumps(data, indent=2)
        else:
            return data


class NdffResult(dict):
    """
    A NdffResult is an object (actually a dict) holding all (http) information retrieved after the Connector
    fired a http request to the NDFF Api servers and received an answer.

    It is used to have a standard way of capturing the NDFF responses for all kind of HTTP requests fired to the API

    It is mostly created by the 'handle_response' method of the ndff.Api module as this is the central part of the
    ndff.Api object to handle the http responses
    """

    def __init__(
        self,
        waarneming_id,
        object_type,
        object_id,
        ndff_uri,
        http_method,
        http_status,
        http_response,
        id=None,
        tstamp=None,
        related_uri=None,
    ):
        """
        Constructor for an NdffResult

        :param waarneming_id:
        :param object_type:
        :param object_id:
        :param ndff_uri:
        :param http_method:
        :param http_status:
        :param http_response:
        :param id:
        :param tstamp:
        :param related_uri:
        """
        super().__init__()
        # remove query part from nddf_uri because: after searching the uri's contain "?format=javascript" sometimes
        o = urlparse(ndff_uri)
        ndff_uri = o.scheme + "://" + o.netloc + o.path
        self["waarneming_id"] = waarneming_id
        self["id"] = id
        self["object_type"] = object_type
        self["object_id"] = object_id
        self["ndff_uri"] = ndff_uri
        self["http_method"] = http_method
        self["http_status"] = http_status
        # self['http_response'] = json.dumps(http_response)
        # 20220422 NOT returning a string representation of the http response, but the response as we got it after 'handling'
        # it IS very much possible that this is now a dict or a json object...
        self["http_response"] = http_response
        self["tstamp"] = tstamp
        # 20221211 RD: ALSO add the 'related_uri' uri to the output IF it is there
        self["related_uri"] = related_uri

    @staticmethod
    def quote_if_needed(value: str) -> str:
        """
        Util method to return a value in between double quotes when the parameter value contains a comma

        :param value:
        :return:
        """
        ret = value
        if "," in value:
            # create quoted value
            ret = f'"{value}"'
        return ret

    def as_tuple(self) -> tuple:
        """
        Method to return self as a tuple of strings (to be used in logging etc.)
        """
        if self["tstamp"] is None:
            self["tstamp"] = datetime.now().isoformat(timespec="seconds")
        return (
            self["tstamp"],
            self["object_type"],
            self["http_method"],
            self["http_status"],
            self["object_id"],
            self["ndff_uri"],
            self["related_uri"],
            self.quote_if_needed(self["http_response"]),
        )


class NdffBulkset(dict):
    """
    A NdffBulkset is an empty dict following the content rules of the NDFF

    Depending on if NdffObservations (or other) are to be updated or freshly uploaded,
    it is up to the user to put them in the PUT or POST part...
    """

    def __init__(self):
        """
        Constructor for an empty NdffBulkset
        """
        super().__init__()
        self["POST"] = {"observations": [], "persons": [], "datasets": []}
        self["PUT"] = {"observations": [], "persons": [], "datasets": []}
