# cavaliba - sirene - field.py

"""
Field: base class for one schema field's in-memory value(s) on an Instance.
Subclassed per dataformat in the sibling field_<type>.py modules (see
fieldtypes/__init__.py for the full list).

Method inventory:

Module function:
    split_composite

Construction / debug:
    __init__, print

Cardinality / value access:
    is_multi, is_field_true, get_attribute, get_value, get_first_value,
    get_cardinal3

Export / serialization:
    get_eav_list, get_eav_format, get_snapshot_value, get_ui_dataformat,
    get_datapoint_ui_detail, get_datapoint_ui_edit, get_datapoint_for_export,
    get_csv_cell

    /!\\ get_datapoint_ui_detail() and get_datapoint_ui_edit() build two
    DIFFERENT dicts that both reach the templates as "item" - the per-dataformat
    side-by-side is in get_datapoint_ui_edit()'s docstring. Also see
    project/design/field_templates.txt.

Mutation (form/import) - in-memory only:
    merge_request, merge_import

Validation:
    is_valid
"""

from django.conf import settings

from app_home.log import ERROR, log

# --------------------------------------------------------
#  INSTANCE FIELDS
# --------------------------------------------------------

# dataformats that have a app_data/templates/app_data/fields/_<dataformat>.html
# UI template. The datapoint builders below normalize to "unknown" for anything
# else, because the UI resolves the template from datapoint["dataformat"] by
# name - an unlisted value would raise TemplateDoesNotExist (HTTP 500).
#
# /!\ deliberately NOT models.FIELD_FORMAT_CHOICE: that still accepts "user",
# "group" and "role" (and, in the 0001_initial migration, "json"), none of which
# have a template. Schema.update_from_dict() rewrites those three to
# "schema" + dataformat_ext on import, but Schema.from_obj() reads dataformat
# straight from the data_field table with no normalization and no migration ever
# rewrote existing rows - so a DB upgraded in place from a pre-4.0 release can
# still hold them. Keep this set in sync with the fields/ template inventory.
UI_TEMPLATE_DATAFORMATS = {
    "boolean",
    "date",
    "datetime",
    "enumerate",
    "external",
    "file",
    "float",
    "int",
    "ipv4",
    "password",
    "permission",
    "schema",
    "string",
    "text",
    "time",
    "unknown",
}


def split_composite(data):
    """Flatten a str/list/dict struct into a flat list [a, b, c, ...]; comma-separated
    strings are split, nested lists are recursed into, dicts are dropped."""

    reply = []

    if type(data) is str:
        result = [x.strip() for x in data.split(",")]
        reply += result

    elif type(data) is list:
        for g in data:
            reply += split_composite(g)

    elif type(data) is dict:
        pass

    else:
        result = [data]
        reply += result

    return reply


class Field:
    # self.fieldname
    # self.fieldschema = {...}
    # self.value = []
    # self.errors

    def __init__(self, fieldname=None, fieldschema=None, alljson=None):
        """Read display/format/cardinality options from fieldschema, then set self.value
        from alljson[fieldname] if present, else from default_value."""

        # to overload in subclass:
        # def __init__(self, fieldname, fieldschema, alljson):
        #     super().__init__(fieldname, fieldschema, alljson)

        #  removed 3.15.7
        # self.fieldschema = fieldschema

        self.fieldname = fieldname

        self.displayname = fieldschema.get("displayname", fieldname)
        self.dataformat = fieldschema.get("dataformat", "string")
        self.dataformat_ext = fieldschema.get("dataformat_ext", "")
        self.cardinal_min = fieldschema.get("cardinal_min", 0)
        self.cardinal_max = fieldschema.get("cardinal_max", 1)
        self.is_enabled = fieldschema.get("is_enabled", True) in settings.TRUE_LIST
        self.value = []
        self.default_value = fieldschema.get("default_value", None)
        self.order = fieldschema.get("order", 100)
        self.page = fieldschema.get("page", "Data")
        self.description = fieldschema.get("description", "")

        # external, schema, enumerate, external_schema, external_enumerate
        # self.is_injected = False
        # self.injected_type = ""
        self.is_injected = fieldschema.get("is_injected", False)
        self.injected_type = fieldschema.get("injected_type", "")
        self.errors = []

        # default from schema is a "ui string" ; need to convert to a list
        # self.default_value = [self.default_value]

        # json value is already a list : either from DB
        # or from merge_new_post_request POST.getlist()

        # set value from json
        if alljson:
            self.value = alljson.get(fieldname, None)
            if type(self.value) is not list:
                self.value = []
        #  else set value to default_value
        else:
            if self.default_value:
                if type(self.default_value) is list:
                    self.value = self.default_value
                else:
                    self.value = [self.default_value]

        # if exists, with mandatory value / none set / default available
        if self.cardinal_min > 0:
            if len(self.value) == 0:
                if self.default_value:
                    if type(self.default_value) is list:
                        self.value = self.default_value
                    else:
                        self.value = [self.default_value]

    def print(self):
        """Print "fieldname = value" (debug helper)."""
        try:
            # value_str = '|'.join(self.value)
            print(f"  Field: {self.fieldname} = {self.value}")
        except Exception:
            pass

    def is_multi(self):
        """Return True if the field accepts more than one value (cardinal_max != 1)."""
        return self.cardinal_max != 1

    def is_field_true(self):
        """Return False; overloaded in FieldBoolean to test a single boolean value."""
        return False

    def get_eav_list(self):
        """
        OUT: list of string from self.value for DB DataEAV value field
        """
        try:
            return [str(i) for i in self.value]
        except Exception:
            return ["?"]

    def get_eav_format(self):
        """Return the dataformat string stored in the DataEAV row; overloaded when a
        variant needs a qualified format (e.g. "ipv4:subnet", "schema:classname")."""
        return self.dataformat

    # TODO : split raw/processed field
    def get_attribute(self):
        """Return the raw JSON value list, e.g. ["a", "b", ...]; overloaded in
        FieldSchema/FieldPermission to resolve to related objects instead."""
        return self.value

    def get_value(self):
        """Return self.value as-is (list of raw values)."""
        return self.value

    def get_snapshot_value(self):
        """Return value for revision diff snapshots. Drops None/empty-string entries so
        unset-vs-blank doesn't produce spurious revision diffs. Override in subclasses
        (e.g. boolean) to normalize representations differently."""
        return [v for v in self.value if v not in (None, "")]

    # return first value / process in overload
    def get_first_value(self):
        """Return self.value[0], or "" if empty; overloaded where a typed cast is needed
        (e.g. FieldBoolean, FieldSchema)."""
        try:
            return self.value[0]
        except Exception:
            return ""

    def get_cardinal3(self):
        """Return (current count, cardinal_min, cardinal_max) for this field's value."""

        cardinal = len(self.value)
        cardinal_min = self.cardinal_min
        cardinal_max = self.cardinal_max

        return cardinal, cardinal_min, cardinal_max

    def get_ui_dataformat(self):
        """Return self.dataformat if it has a fields/_<dataformat>.html template,
        else "unknown" (logged). Both datapoint builders go through this so the UI
        never resolves a template name that doesn't exist - see
        UI_TEMPLATE_DATAFORMATS."""

        if self.dataformat in UI_TEMPLATE_DATAFORMATS:
            return self.dataformat

        log(ERROR, app="data", view="field", action="get_ui_dataformat", status="KO",
            data=f"no UI template for dataformat '{self.dataformat}' on field '{self.fieldname}' - fallback to unknown")  # fmt: skip
        return "unknown"

    def get_datapoint_ui_detail(self):
        """Build the datapoint dict consumed by the read-only UI templates -
        display_mode list / detail / print (and tech, which renders as detail).
        All four get this same dict; what differs between them is template
        markup, not this structure.

        Shape here vs get_datapoint_ui_edit() - see that method's docstring for
        the full side-by-side. The short version: "value" is a SCALAR here
        (self.value[0], or ", ".join(...) when multi), while the edit builder
        returns the raw list. Subclasses override both and diverge further.

        Keys: fieldname, displayname, description, dataformat, dataformat_ext,
        is_multi, cardinal_min, cardinal_max, bigset (always False), value.
        Plus, from subclasses: "schema" (FieldSchema, the target classname) and
        "queryset" (FieldFile, the matching DataFile objects).

        /!\\ This dict is also a public payload: app_data/api/asset.py returns
        it as JSON, and app_sirene reads it by key (ui_dict["severity"]["value"]
        and friends). Adding a key is safe; renaming one or changing a value's
        type is a breaking change."""

        datapoint = {}
        datapoint["fieldname"] = self.fieldname
        datapoint["displayname"] = self.displayname
        datapoint["description"] = self.description
        datapoint["dataformat"] = self.get_ui_dataformat()
        datapoint["dataformat_ext"] = self.dataformat_ext
        datapoint["is_multi"] = self.is_multi()
        datapoint["cardinal_min"] = self.cardinal_min
        datapoint["cardinal_max"] = self.cardinal_max
        datapoint["bigset"] = False
        datapoint["value"] = ""

        if self.value:
            if len(self.value) == 1:
                datapoint["value"] = self.value[0]
            elif len(self.value) > 1:
                # TODO : join self.value.display only
                try:
                    # datapoint["value"] = ', '.join(str(self.value))
                    datapoint["value"] = ", ".join([str(z) for z in self.value])
                    # datapoint["value"] = 'XXX'
                except Exception:
                    pass

        return datapoint

    def get_datapoint_ui_edit(self):
        """Build the datapoint dict consumed by the new/edit form templates -
        display_mode edit. Overloaded per dataformat.

        Keys: fieldname, displayname, description, dataformat, dataformat_ext,
        is_multi, value. No cardinal_min/cardinal_max and no bigset at this
        level (FieldSchema adds bigset); FieldFile adds "queryset".

        /!\\ Both builders feed the same "item" template variable, with
        DIFFERENT shapes. Getting this wrong is the classic bug in
        fields/_<dataformat>.html, so, per dataformat, what "value" holds:

                      ui_detail (list/detail/print)  ui_edit (edit/new)
            string    scalar                         list    -> value.0
            text      scalar                         list    -> value.0
            date      scalar                         list    -> value.0
            datetime  scalar                         list    -> value.0
            time      scalar                         list    -> value.0
            int       " ".join(values) - a STRING     same STRING -> value
            float     " ".join(values) - a STRING     same STRING -> value
            password  ["****"] -> value.0            ["****"] -> value.0
            file      [uuid] + queryset              [uuid] + queryset
            schema    SELECTED  [{key,display,id,is_enabled}]
                                                     CANDIDATES
                                                     [{key,display,selected,
                                                       is_enabled}]
            enumerate SELECTED entries [{value,widget,...}]
                                                     ALL enabled entries
                                                     + "selected" flag
            permission SELECTED [{key,display,id}]   ALL SirenePermissions
                                                     + "selected" flag
            external  always empty (resolution injects a separate field)

        Other divergences: "schema" exists only in ui_detail, while ui_edit
        rewrites "dataformat_ext" to the target classname instead; "bigset" is
        always False in ui_detail but computed in ui_edit (select2 ajax
        threshold).

        Reference: project/design/field_templates.txt."""

        # default / parent behavior
        # surcharged in subclass

        datapoint = {}
        datapoint["fieldname"] = self.fieldname
        datapoint["displayname"] = self.displayname
        datapoint["description"] = self.description
        datapoint["dataformat"] = self.get_ui_dataformat()
        datapoint["dataformat_ext"] = self.dataformat_ext
        datapoint["is_multi"] = self.is_multi()
        datapoint["value"] = self.value

        return datapoint

    def get_datapoint_for_export(self):
        """Return the exportable value: single value if not multi (or "" if empty),
        else the full value list."""

        if not self.is_multi():
            try:
                return self.value[0]
            except Exception:
                # empty
                return ""
        return self.value

    def get_csv_cell(self):
        """Return the CSV cell content: single value if not multi, else values joined by "^"."""
        if not self.is_multi():
            try:
                return self.value[0]
            except Exception:
                # empty
                return ""
        else:
            try:
                return "^".join(self.value)
            except Exception:
                return ""

        return self.value

    # MERGE REQUEST
    #  -------------
    def merge_request(self, request):
        """Set self.value from request.POST.getlist(fieldname); overloaded per dataformat
        for type-specific parsing (int, float, boolean, file, password, ...)."""

        # default
        data = request.POST.getlist(self.fieldname, default=[])

        self.value = []

        # default behavior
        if not data:
            return

        if len(data) == 0:
            return

        if type(data) is not list:
            try:
                if len(data) > 0:
                    self.value = [data]
            except Exception:
                return
        else:
            for i in data:
                if len(i) > 0:
                    self.value.append(i)

    #  IMPORT
    # ------
    def merge_import(self, data):
        """Set self.value from an import/API payload via split_composite(); overloaded
        per dataformat where composite-splitting is wrong (date, string, text, ...)."""
        # default behavior
        self.value = split_composite(data)

    def merge_append(self, data, remove=False):
        """Combine an import/API payload with the field's current value instead of
        replacing it: parse `data` via merge_import() as usual, then union (or, if
        remove=True, subtract) the parsed values into/from the previous self.value.
        Single-valued fields (cardinal_max == 1) have nothing to append/remove, so
        they fall back to plain merge_import() (identical to the "update" action)."""

        if not self.is_multi():
            self.merge_import(data)
            return

        previous = list(self.value)
        self.merge_import(data)
        incoming = self.value

        if remove:
            self.value = [v for v in previous if v not in incoming]
        else:
            self.value = previous + [v for v in incoming if v not in previous]

    def is_valid(self):
        """Check cardinality (cardinal_min/cardinal_max) against len(self.value);
        overloaded per dataformat to add type/format/range checks."""

        # check cardinality
        cardinal = len(self.value)
        if cardinal < self.cardinal_min:
            return False
        if self.cardinal_max > 0 and cardinal > self.cardinal_max:
            return False

        return True
