# c(c) cavaliba.com - data - dataview.py

"""
DataView: named column layout for a schema's list UI ("_dataview" Instances),
resolving which columns to show and how to extract/format each Instance's cell
values. dataview_for_request() picks the active view (request > session >
single-choice > CLASSNAME_default > built-in default) for a given classname.

Method inventory:

Module functions:
    get_dataview_dict, get_dataview_content_by_name, dataview_for_request

DataView:
    __init__, get_columns, filter, print
"""

import re

import yaml
from django.utils import timezone
from django.utils.translation import gettext as _

from app_data.data import Instance, field_export_match
from app_data.datetimeutil import iso_seconds
from app_data.models import DataEAV

# columns not backed by a schema field, always kept regardless of the fields filter
# (matches id/classname/keyname being unaffected everywhere else - see
# field_export_match()). displayname/last_update/last_sync are deliberately NOT
# here even though they're builtin attributes too: they route through
# field_export_match() like a regular field, so an explicit "!displayname" (etc.)
# filter can exclude them.
_DATAVIEW_BUILTIN_SOURCES = {"keyname", "classname", "is_enabled"}


# ---------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------
def get_dataview_dict(schemaname):
    """
    get available dataviews for schema
    use DataEAV cache

    IN:  schema name (string
    OUT: dict { keyname:displayname , ... }
    """
    reply = {}

    eav_entries = DataEAV.objects.filter(
        classname="_dataview", fieldname="target_class", value=schemaname
    ).values("keyname", "displayname")

    # <QuerySet [{'keyname': 'aaaaa', 'displayname': 'bbbb'},  ... ]

    for adict in eav_entries:
        try:
            k = adict["keyname"]
            v = adict["displayname"]
            reply[k] = v
        except Exception:
            pass

    return reply


def get_dataview_content_by_name(dataview_name):
    """Return the parsed YAML "content" of a "_dataview" Instance by keyname, or None."""

    reply = None

    instance = Instance.from_keyname(classname="_dataview", keyname=dataview_name)
    if not instance:
        return

    content = {}
    try:
        content = instance.fields["content"].value[0]
        reply = yaml.safe_load(content)
    except Exception as e:
        print(f"ERR - can't access dataview content ({dataview_name}): {e}")
        return

    return reply


def dataview_for_request(request=None, classname=None, update_session=False):
    """
    get requested dataview (request, session, default)
    provide dataview object
    update session

    Sets dataview.from_request True only when the name came from an explicit
    "dv" in this request (and the view it resolved to really targets classname),
    so instance_list can tell a dropdown switch from a session-remembered view.

    return: dataview or None
    """
    if not classname:
        return

    if not request:
        return

    # DATAVIEW
    # --------
    # Transform to a DATAVIEW structure (subset/superset of columns, widgets, ...)
    # 1. get available dataviews (default, global, per user)
    # 2. get requested dataview (or default dataview)
    # 3. loop over instances / extract columns / build structure

    # 1. Get Dataview :  request > session > single > CLASS_default > DEFAULT built-in
    dataview_name = None
    dv_found = False
    dv_from_request = False
    dataview_default = classname + "_default"

    dataview_selector = get_dataview_dict(classname)

    #  request
    dataview_name = request.GET.get("dv", None)
    if not dataview_name:
        dataview_name = request.POST.get("dv", None)

    if dataview_name:
        if dataview_name == dataview_default:
            dv_found = True
            dv_from_request = True
        else:
            m = re.compile(r"[a-zA-Z0-9()_/.-]*$")
            if m.match(dataview_name):
                if dataview_name in dataview_selector:
                    dv_found = True
                    dv_from_request = True

    #  in session ?
    if not dv_found:
        try:
            dataview_name = request.session["dataviews"][classname]
            if dataview_name:
                m = re.compile(r"[a-zA-Z0-9()_/.-]*$")
                if m.match(dataview_name):
                    if dataview_name in dataview_selector:
                        dv_found = True
        except Exception:
            pass

    # single entry in selector ?
    if not dv_found:
        if len(dataview_selector) == 1:
            # dataview_name = dataview_selector[0]
            dataview_name = next(iter(dataview_selector))
            dv_found = True

    # CLASSNAME_default
    if not dv_found:
        if dataview_default in dataview_selector:
            dataview_name = dataview_default
            dv_found = True

    #  get or built-in default
    if dv_found:
        dataview = DataView(keyname=dataview_name)
    else:
        dataview = DataView()

    if dataview.target_class != classname:
        # wrong class, use a default view
        dataview = DataView()
        dv_from_request = False

    #  store dataview in user session
    if update_session:
        if dataview_name:
            if "dataviews" not in request.session:
                request.session["dataviews"] = {classname: dataview_name}
                # request.session.modified = True
            else:
                request.session["dataviews"][classname] = dataview_name
                request.session.modified = True

    # store request specifics in dataview object
    dataview.from_request = dv_from_request
    if dataview_default not in dataview_selector:
        dataview.default = dataview_default
    # dataview.selector = {}
    dataview.selector = dataview_selector
    # for k,v in dataview_selector.items():
    #     #dataview.selector[k] = v.displayname
    #     dataview.selector[k] = v

    return dataview


# -------------------------------------------
# DataView CLASS
# -------------------------------------------


class DataView:
    def __init__(self, keyname=None):
        """Build the built-in default view (keyname/displayname/last_update columns)
        if keyname is None or not found/bound; otherwise load and parse the
        "_dataview" Instance's target_class/query/content/columns.

        self.query is the view's optional stored search filter ("" when unset):
        instance_list loads it into its search box when the user switches to
        this view - see from_request below and dataview_for_request().

        self.columns is a list of {"label": ..., "fieldname": ..., "popover": ...,
        "filter": ...} dicts (one per declared column): "label" is the header
        text (a column's "from:" rename, or the plain field name), "fieldname"
        is the real underlying field name (see _column_source()) - None for a
        dict entry with no "from:" and no "popover:"/"filter:" override.
        "popover"/"filter" are optional per-column YAML booleans (instance_list's
        column-header "?" popup and its "Filter" button) - both default to True
        regardless of fieldname, so an existing dataview's display is unchanged;
        a dataview author sets "filter: no" (or "popover: no") on a column whose
        fieldname isn't actually searchable (e.g. an injected/external
        subfield) to suppress a button that would otherwise
        silently produce a query matching nothing."""

        self.instance_id = None
        self.target_class = None
        self.keyname = keyname
        self.displayname = _("Default View")
        self.is_enabled = True
        self.query = ""
        self.content = {"columns": ["keyname", "displayname", "last_update"]}
        self.columns = [
            {"label": "keyname", "fieldname": "keyname", "popover": True, "filter": True},
            {"label": "displayname", "fieldname": "displayname", "popover": True, "filter": True},
            {"label": "last_update", "fieldname": "last_update", "popover": True, "filter": True},
        ]

        # request specifics
        self.default = None  # name of default if not in selector
        self.selector = None  # list of available dataviews for target_class
        # True when this view was picked by an explicit "dv" in the current
        # request (the UI's dataview dropdown), as opposed to the session or a
        # default fallback - see dataview_for_request(). instance_list only
        # loads self.query into the search box on such a switch, so a query the
        # user typed afterwards isn't overwritten on every later page view.
        self.from_request = False

        if not keyname:
            return

        # query _dataview Schema for keyname
        instance = Instance.from_keyname(classname="_dataview", keyname=keyname)
        if not instance:
            return
        if not instance.is_bound:
            return

        self.instance_id = instance.id
        try:
            self.target_class = instance.fields["target_class"].value[0]
        except Exception:
            self.target_class = None
        self.displayname = instance.displayname
        self.is_enabled = instance.is_enabled
        # get_attribute_first() (not the instance.fields[...] idiom above) so a
        # DB whose _dataview schema predates the "query" field reads "" instead
        # of raising - see app_home/migrator.py add_dataview_query_field()
        self.query = instance.get_attribute_first("query")
        try:
            raw = instance.fields["content"].value[0]
        except Exception:
            raw = ""

        try:
            self.content = yaml.safe_load(raw)
        except Exception as e:
            print(f"ERR - can't parse dataview content ({keyname}): {e}")
            return

        if type(self.content) is not dict:
            print(f"ERR - dataview content is not a mapping ({keyname})")
            self.content = {"columns": ["keyname", "displayname", "last_update"]}
            return

        # Extract columns
        # columns:
        #   - nice name:
        #       from: keyname
        #   - col1
        #    - site:
        #        from: mysitename_col2
        #
        yaml_columns = self.content.get("columns", None)
        computed_columns = []
        if isinstance(yaml_columns, list):
            for head in yaml_columns:
                fieldname = self._column_source(head)
                popover, show_filter = self._column_flags(head)
                if type(head) is str:
                    computed_columns.append(
                        {
                            "label": head,
                            "fieldname": fieldname,
                            "popover": popover,
                            "filter": show_filter,
                        }
                    )
                elif type(head) is dict:
                    for col, _nouse in head.items():
                        computed_columns.append(
                            {
                                "label": col,
                                "fieldname": fieldname,
                                "popover": popover,
                                "filter": show_filter,
                            }
                        )
        elif yaml_columns:
            print(f"ERR - dataview 'columns' is not a list ({keyname})")
        self.columns = computed_columns

        # NEXT: if no keyname/displayname , add keyname ?

    def _column_source(self, head):
        """Return the underlying instance field name for a raw column head entry
        (str column, or dict {label: {"from": source, ...}}), or None if
        unresolvable. A dict entry's own key is used as the fieldname when it
        carries a "popover:"/"filter:" override but no "from:" (same field,
        just flags on its plain name); a dict entry with neither "from:" nor
        those overrides is a pure label with no real field behind it."""

        if type(head) is str:
            return head
        if type(head) is dict:
            for label, operator_line in head.items():
                if not isinstance(operator_line, dict):
                    continue
                if "from" in operator_line:
                    return operator_line["from"]
                if "popover" in operator_line or "filter" in operator_line:
                    return label
        return None

    def _column_flags(self, head):
        """Return (popover, filter) booleans for a raw column head entry, from
        optional "popover:"/"filter:" YAML keys (dict form only) - both default
        to True (for a plain string column, or when the dict form omits them),
        so an existing dataview's popover/Filter-button display is unchanged
        unless a column explicitly opts out."""

        if type(head) is dict:
            for _label, operator_line in head.items():
                if not isinstance(operator_line, dict):
                    continue
                return bool(operator_line.get("popover", True)), bool(
                    operator_line.get("filter", True)
                )
        return True, True

    def _column_passes_filter(self, head, fields):
        """True if a raw column head should be kept: columns whose source is in
        _DATAVIEW_BUILTIN_SOURCES (keyname/classname/is_enabled) and columns with
        no resolvable source field always pass; other columns (including
        displayname/last_update/last_sync) are matched against fields via
        field_export_match()."""

        source = self._column_source(head)
        if source is None or source in _DATAVIEW_BUILTIN_SOURCES:
            return True
        return field_export_match(source, fields)

    @staticmethod
    def _format_datetime_cell(dt, mode):
        """Format a last_sync/last_update cell: user-friendly "YYYY-MM-DD HH:MM:SS"
        in the active timezone for on-screen mode="datapoint" (matching the
        |date template filter used on instance_detail - a plain strftime() would
        print dt's own tzinfo, i.e. UTC, instead of the active timezone), strict
        ISO 8601 with second precision for mode="value"/"csv" (machine-readable
        export - also keeps JSON serializable, since a raw datetime isn't). ""
        when dt is None."""

        if not dt:
            return ""
        if mode == "datapoint":
            return timezone.localtime(dt).strftime("%Y-%m-%d %H:%M:%S")
        return iso_seconds(dt)

    def _cell_for_field(self, instance, name, mode):
        """Return one export/UI cell for a plain field/attribute name: a builtin
        instance attribute (keyname/classname/displayname/is_enabled/last_update/
        last_sync - the last two via _format_datetime_cell()) or a real schema
        field (value depends on mode: datapoint/value/csv). Returns "" for
        anything else (e.g. a stale/renamed field no longer on the schema).
        Shared by filter() (per the view's own column defs, both the plain and
        the "from:"-rename column forms) and cell() (per one arbitrary field
        name, as app_data.exporter resolves an explicit column list)."""

        if name == "keyname":
            return instance.keyname
        if name == "classname":
            return instance.classname
        if name == "displayname":
            return instance.displayname
        if name == "is_enabled":
            return "X" if instance.is_enabled else ""
        if name == "last_update":
            return self._format_datetime_cell(instance.last_update, mode)
        if name == "last_sync":
            return self._format_datetime_cell(instance.last_sync, mode)
        if name in instance.fields:
            if mode == "value":
                return instance.fields[name].get_value()
            if mode == "datapoint":
                return instance.fields[name].get_datapoint_ui_detail()
            if mode == "csv":
                return instance.fields[name].get_csv_cell()
        return ""

    def get_columns(self, fields=None, override_columns=None):
        """Return the list of column names from self.content["columns"], for
        export: the underlying source field name (see _column_source()) rather
        than the view's display label/rename, so exported files stay importable
        (a dataview labeling a "keyname"-backed column "Login" still exports a
        "keyname" header/key, not "Login"). Falls back to the label only when the
        column has no resolvable source (e.g. a dict entry with no "from").
        `fields` (see field_export_match()) drops columns whose underlying
        instance field doesn't match; builtin columns and columns with no
        resolvable source field are always kept. `classname`/`keyname` are
        guaranteed present (prepended if the columns considered don't already
        cover them, by source - a renamed `from: keyname` column counts) so the
        export stays re-importable via `cavaliba load` even if they're never
        displayed. Columns sharing the same source (e.g. two different labels
        both `from: classname`) are de-duplicated, keeping only the first, to
        avoid two identical headers. `override_columns`, when given (e.g. from
        the export modal's dv_fields textbox), replaces this view's own declared
        columns entirely - each entry is a plain real field name (not resolved
        against any "from:" rename), still subject to the same `fields` filter
        and keyname/classname guarantee as the view's own columns would be."""

        reply = []
        sources_seen = set()
        raw = (
            override_columns
            if override_columns is not None
            else (self.content.get("columns") or [])
        )
        for entry in raw:
            if not self._column_passes_filter(entry, fields):
                continue
            source = self._column_source(entry)
            if source:
                if source in sources_seen:
                    continue
                sources_seen.add(source)
                reply.append(source)
            elif type(entry) is dict:
                reply.append(list(entry.keys())[0])
            else:
                reply.append("")
        if "keyname" not in sources_seen:
            reply = ["keyname"] + reply
        if "classname" not in sources_seen:
            reply = ["classname"] + reply
        return reply

    def filter(
        self, instance=None, mode="datapoint", value_only=False, fields=None, override_columns=None
    ):
        """

        IN:
          * dataview() object, instance() object
          * mode: datapoint|value|csv

        OUT:
          * array ["val1","val2", {}, {},{} ...]

        values:
            * string if keyname/displayname/...
            * mode=datapoint : datapoint dict {}
            * mode=value
                {'fieldname': 'mystring',
                'displayname': 'MyString',
                'description': 'A string',
                'dataformat': 'string',
                'dataformat_ext': None,
                'is_multi': False,
                'bigset': False,
                'value':  'data for test1-01'
                        {} for more complex fields like enumerate, user
                }
            * mode=csv : value or val1^val2^val3 ... if multi

        `fields` (see field_export_match()) drops columns whose underlying
        instance field doesn't match, mirroring get_columns(fields=...) so the
        returned array stays aligned with its header. For mode in
        value/csv (export), `classname`/`keyname` are guaranteed present and
        duplicate-source columns are skipped, mirroring get_columns() - see
        its docstring - so exported rows stay aligned with get_columns()'s
        header. mode=datapoint (UI table) skips that guarantee instead,
        since its header is self.columns, which carries no such guarantee -
        adding hidden columns there would desync row cells from headers.
        `override_columns`, when given, replaces this view's own declared
        columns the same way as get_columns(override_columns=...) - pass the
        same value used to build get_columns() to stay aligned.
        """

        if not instance:
            return

        data = []
        sources_seen = set()

        # get dataview requested columns
        columns_array = (
            override_columns
            if override_columns is not None
            else (self.content.get("columns") or [])
        )

        for head in columns_array:
            if not self._column_passes_filter(head, fields):
                continue
            source = self._column_source(head)
            if source:
                if source in sources_seen:
                    continue
                sources_seen.add(source)

            # no operator, no rename, direct column name
            if type(head) is str:
                data.append(self._cell_for_field(instance, head, mode))

            # operator, rename, ...
            # - newcol:              <= head is dict (col==head)
            #     from: old_col      <= operator_line = operator: operator_value
            #     popover/filter: consumed only for column-header metadata,
            #     not a cell-value source - see _column_flags()

            elif type(head) is dict:
                # `source` (computed above via _column_source()) already
                # resolves both the explicit "from:" case and the "popover:"/
                # "filter:"-only, same-field case - same builtin-attribute /
                # real-field resolution as the plain (non-renamed) column form
                cell = self._cell_for_field(instance, source, mode) if source else ""
                data.append(cell)

            else:
                data.append("")

        if mode != "datapoint":
            if "keyname" not in sources_seen:
                data = [instance.keyname] + data
            if "classname" not in sources_seen:
                data = [instance.classname] + data

        return data

    def get_csv_fieldnames(self):
        """Deduped real source field names from this view's own columns
        definition, in view order - no forced keyname/classname/is_enabled, no
        fields filter. Used by the Exporter to decide which of its column-selector
        boxes start checked when the caller arrived from this view's list page
        (see views_exporter._checked_columns())."""

        reply = []
        seen = set()
        for entry in self.content.get("columns") or []:
            source = self._column_source(entry)
            name = source or (list(entry.keys())[0] if type(entry) is dict else None)
            if name and name not in seen:
                seen.add(name)
                reply.append(name)
        return reply

    @staticmethod
    def parse_dv_fields(fields_text):
        """Parse a comma-separated column list into a deduped list of raw column
        tokens (real field names), in the order given, or None if blank. Used for
        the uniform field-list override of get_columns()/filter(), and by
        app_data.exporter to split an ExportSpec's explicit `columns` - each token
        is a real field name directly, not resolved against the view's own "from:"
        renames."""

        if not fields_text or not fields_text.strip():
            return None
        seen = set()
        reply = []
        for tok in fields_text.split(","):
            tok = tok.strip()
            if tok and tok not in seen:
                seen.add(tok)
                reply.append(tok)
        return reply

    def cell(self, instance, name, mode="csv"):
        """Public one-cell renderer for an arbitrary field/attribute name, used by
        app_data.exporter when the user typed an explicit column list and there is
        no dataview to resolve it against. Same resolution as the dataview's own
        rows (builtin attributes, then real schema fields, "" when unknown), so an
        explicit-column export and a view-driven one format identically."""

        return self._cell_for_field(instance, name, mode)

    def print(self):
        """Print a human-readable dump of the dataview (debug helper)."""
        print("_dataview")
        print(f"    keyname:        {self.keyname}")
        print(f"    instance_id:    {self.instance_id}")
        print(f"    target_class:   {self.target_class}")
        print(f"    is_enabled:     {self.is_enabled}")
        print(f"    displayname:    {self.displayname}")
        print(f"    query:          {self.query}")
        print(f"    columns:        {self.columns}")
        print(f"    content:        {self.content}")
