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

import csv
import json
import os

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

from app_data.data import Instance
from app_data.permissions import has_schema_write_permission, load_permission

# from app_data.data import load_instance, load_schema
from app_data.schema import Schema
from app_data.user import User
from app_home.configuration import get_configuration
from app_home.load import load_home
from app_home.log import INFO, WARNING, log


# ---------------------------------------------------------------------
# Load broker for one or more objects provided as a LIST of DICTs
# ---------------------------------------------------------------------
def load_broker(datalist=None, aaa=None, force_action=None, force_schema=None):
    """
    Load a batch of object into the database

    IN:
       datalist: [ {}, {}, {}, ... ]
       aaa:
       force_action: None, create, update, delete, init, enable, disable, append, unappend
       force_schema: None, or a classname forced onto every entry - applied
           unconditionally, before the missing-classname check, so it can also
           supply a classname a CSV/YAML/JSON entry didn't have

    OUT:
       reply struct (dict) : count, count_ok, count_ko, errors=[]
    """

    reply = {
        "count": 0,
        "count_ok": 0,
        "count_ko": 0,
        "errors": [],
    }

    if not datalist:
        reply["errors"].append("no data provided")
        return reply

    if type(datalist) is not list:
        reply["errors"].append("invalid data provided (not a list)")
        return reply

    if not aaa:
        reply["errors"].append("missing identity")
        return reply

    if "perms" not in aaa:
        reply["errors"].append("missing aaa permissions")
        return reply

    for datadict in datalist:
        err = None

        reply["count"] += 1

        if type(datadict) is not dict:
            reply["count_ko"] += 1
            reply["errors"].append(f"invalid entry: {datadict}")
            continue

        if force_schema:
            datadict["classname"] = force_schema

        classname = datadict.get("classname", None)
        if not classname:
            reply["count_ko"] += 1
            reply["errors"].append(f"missing classname in {datadict}")
            continue

        if len(classname) == 0:
            # unknown object class : NEXT : log error
            reply["count_ko"] += 1
            reply["errors"].append(f"invalid classname in {datadict}")
            continue

        if force_action:
            datadict["_action"] = force_action

        # load per schema
        if classname == "_schema":
            err = load_schema(datadict=datadict, aaa=aaa)

        # left as regular DB/ORM
        elif classname == "_permission":
            err = load_permission(datadict=datadict, aaa=aaa)

        elif classname == "_home":
            err = load_home(datadict=datadict, aaa=aaa)

        else:
            # internal: _pipeline, _dataview, _apikey, _enumerate
            # apps: sirene_*, ipam_*, status_monitor, ...
            # user-defined schema
            # v4.0 - user, group, role
            err = load_instance(datadict=datadict, aaa=aaa)

        if err:
            reply["count_ko"] += 1
            reply["errors"].append(err)
        else:
            reply["count_ok"] += 1

    return reply


# --------------------------------------------------------
# LOADER / IMPORT
# Global LOADER : class, schema, instance, static
# --------------------------------------------------------


def load_schema(datadict=None, verbose=True, aaa=None):
    """
    IN:
         datadict['keyname']  : mandatory classname(!)

    OUT:
        None or error string
    """

    if not datadict:
        return "load_schema: no data"

    if not has_schema_write_permission(aaa=aaa):
        log(WARNING, aaa=aaa, app="data", view="schema", action="load", status="DENY", data=_("Not allowed"))  # fmt: skip
        return "load_schema: not allowed"

    #  for a _schema, classname is the provided keyname (classname = _schema)
    classname = datadict.get("keyname", None)
    if not classname:
        return f"load_schema: missing classname {datadict}"

    action = datadict.get("_action", "create")

    # delete Schema and Fields
    if action == "delete":
        # TODO: check classname is not builtin (_reserved)
        schema_obj = Schema.delete(classname)
        if schema_obj:
            return
        else:
            return f"load_schema: failed to delete {classname}"

    #  enable
    elif action == "enable":
        # TODO: check classname is not builtin (_reserved)
        schema_obj = Schema.enable(classname)
        if schema_obj:
            return
        else:
            return f"load_schema: failed to enable {classname}"

    # disable
    elif action == "disable":
        # TODO: check classname is not builtin (_reserved)
        schema_obj = Schema.disable(classname)
        if schema_obj:
            return
        else:
            return f"load_schema: failed to disable {classname}"

    # init
    elif action == "init":
        if Schema.exists(classname):
            return
        schema = Schema()
        schema.update_from_dict(datadict, verbose=verbose)
        schema.save()
        # TODO: check save result
        return

    # create (or update)
    elif action == "create":
        schema = Schema.from_name(classname)
        if not schema:
            schema = Schema()
        schema.update_from_dict(datadict, verbose=verbose)
        schema.save()
        return

    # update (only)
    elif action == "update":
        schema = Schema.from_name(classname)
        if not schema:
            return f"load_schema: not found: {classname} "
        schema.update_from_dict(datadict, verbose=verbose)
        schema.save()
        return

    elif action == "noop":
        return

    else:
        log(WARNING, aaa=aaa, app="data", view="schema", action="unknown", status="FAILED", data=f"Unknown load action {action} for schema {classname}")  # fmt: skip

    return f"load_schema: unknown action {action} for {classname}"


def load_instance(datadict=None, aaa=None):

    if not datadict:
        return "load_instance: no data"

    if not aaa:
        log(WARNING, aaa=aaa, app="data", view="instance", action="load", status="DENY", data=_("Not allowed"))  # fmt: skip
        return "load_instance: not allowed"

    classname = datadict.get("classname", None)
    schema = Schema.from_name(classname)
    if not schema:
        return f"load_instance: schema not found: {classname}"

    action = datadict.get("_action", "create")

    keyname = datadict.get("keyname", None)

    # v4.0 -  login backward compatibility for user (login is keyname)
    if not keyname:
        if classname == "user":
            keyname = datadict.get("login", None)

    # schema/user: keyname is an email => resolve to login
    if classname == "user" and keyname and "@" in keyname:
        user = User.get_by_email(email=keyname)
        if not user:
            return f"load_instance: user not found by email {keyname}"
        keyname = user.keyname
        datadict = {**datadict, "keyname": keyname}

    if action == "delete":
        instance = Instance.from_keyname(classname=classname, keyname=keyname, expand=False)
        if instance:
            if instance.has_delete_permission(aaa=aaa):
                r = instance.delete(aaa=aaa)
                if r:
                    log(INFO, aaa=aaa, app="loader", view="instance", action=action, status="OK", data=f"{classname}:{keyname}")  # fmt: skip
                return
            else:
                log(WARNING, aaa=aaa, app="loader", view="instance", action=action, status="DENY", data=_("Not allowed"))  # fmt: skip
        else:
            log(WARNING, aaa=aaa, app="loader", view="instance", action=action, status="DENY", data=_("Not found"))  # fmt: skip
        return f"load_instance: delete failed for {classname}:{keyname}"

    elif action == "disable":
        instance = Instance.from_keyname(classname=classname, keyname=keyname, expand=False)
        if instance:
            if instance.has_update_permission(aaa=aaa):
                r = instance.disable(aaa=aaa)
                if r:
                    log(INFO, aaa=aaa, app="loader", view="instance", action=action, status="OK", data=f"{classname}:{keyname}")  # fmt: skip
                return
            else:
                log(WARNING, aaa=aaa, app="loader", view="instance", action=action, status="DENY", data=_("Not allowed"))  # fmt: skip
        else:
            log(WARNING, aaa=aaa, app="loader", view="instance", action=action, status="DENY", data=_("Not found"))  # fmt: skip
        return f"load_instance: disable failed for {classname}:{keyname}"

    elif action == "enable":
        instance = Instance.from_keyname(classname=classname, keyname=keyname, expand=False)
        if instance:
            if instance.has_update_permission(aaa=aaa):
                r = instance.enable(aaa=aaa)
                if r:
                    log(INFO, aaa=aaa, app="loader", view="instance", action=action, status="OK", data=f"{classname}:{keyname}")  # fmt: skip
                return
            else:
                log(WARNING, aaa=aaa, app="loader", view="instance", action=action, status="DENY", data=_("Not allowed"))  # fmt: skip
        else:
            log(WARNING, aaa=aaa, app="loader", view="instance", action=action, status="DENY", data=_("Not found"))  # fmt: skip
        return f"load_instance: enable failed for {classname}:{keyname}"

    # init only == new instance if doesn't already exist ; else silent noop
    elif action == "init":
        if Instance.exists(classname=classname, keyname=keyname):
            return
        if schema.has_create_permission(aaa):
            instance = Instance(classname=classname)
            if instance:
                instance.merge_import(datadict, aaa=aaa)
                r = instance.save(aaa=aaa)
                if r:
                    log(INFO, aaa=aaa, app="loader", view="instance", action=action, status="OK", data=f"{classname}:{instance.keyname}")  # fmt: skip
                return
            else:
                log(WARNING, aaa=aaa, app="loader", view="instance", action=action, status="DENY", data=_("Not allowed"))  # fmt: skip
                return f"load_instance: init not allowed for {classname}:{keyname}"
        else:
            log(WARNING, aaa=aaa, app="loader", view="instance", action=action, status="DENY", data=_("Couldn't create"))  # fmt: skip
        return f"load_instance: init failed for {classname}:{keyname}"

    # create and/or update if exists
    elif action == "create":
        instance = Instance.from_keyname(classname=classname, keyname=keyname, expand=False)
        if not instance:
            if schema.has_create_permission(aaa):
                instance = Instance(classname=classname)
        if instance:
            if schema.has_create_permission(aaa):
                instance.merge_import(datadict, aaa=aaa)
                r = instance.save(aaa=aaa)
                if r:
                    log(INFO, aaa=aaa, app="loader", view="instance", action=action, status="OK", data=f"{classname}:{instance.keyname}")  # fmt: skip
                return
            else:
                log(WARNING, aaa=aaa, app="loader", view="instance", action=action, status="DENY", data=_("Not allowed"))  # fmt: skip
                return f"load_instance: create not allowed for {classname}:{keyname}"
        else:
            log(WARNING, aaa=aaa, app="loader", view="instance", action=action, status="DENY", data=_("Couldn't create"))  # fmt: skip
        return f"load_instance: create failed for {classname}:{keyname}"

    # don't create, update only if exists
    elif action == "update":
        instance = Instance.from_keyname(classname=classname, keyname=keyname, expand=False)
        if instance:
            if instance.has_update_permission(aaa=aaa):
                instance.merge_import(datadict, aaa=aaa)
                if instance.save(aaa=aaa):
                    log(INFO, aaa=aaa, app="loader", view="instance", action=action, status="OK", data=f"{classname}:{instance.keyname}")  # fmt: skip
                return
            else:
                log(WARNING, aaa=aaa, app="loader", view="instance", action=action, status="DENY", data=_("Not allowed"))  # fmt: skip
                return f"load_instance: update not allowed for {classname}:{keyname}"
        else:
            log(WARNING, aaa=aaa, app="loader", view="instance", action=action, status="DENY", data=_("Not found"))  # fmt: skip
        return f"load_instance: update failed for {classname}:{keyname}"

    # don't create, append/unappend multivalued fields only if exists
    elif action in ("append", "unappend"):
        instance = Instance.from_keyname(classname=classname, keyname=keyname, expand=False)
        if instance:
            if instance.has_update_permission(aaa=aaa):
                instance.merge_import(datadict, aaa=aaa, mode=action)
                if instance.save(aaa=aaa):
                    log(INFO, aaa=aaa, app="loader", view="instance", action=action, status="OK", data=f"{classname}:{instance.keyname}")  # fmt: skip
                return
            else:
                log(WARNING, aaa=aaa, app="loader", view="instance", action=action, status="DENY", data=_("Not allowed"))  # fmt: skip
                return f"load_instance: {action} not allowed for {classname}:{keyname}"
        else:
            log(WARNING, aaa=aaa, app="loader", view="instance", action=action, status="DENY", data=_("Not found"))  # fmt: skip
        return f"load_instance: {action} failed for {classname}:{keyname}"

    elif action == "noop":
        return

    # unknown action
    else:
        log(WARNING, aaa=aaa, app="loader", view="instance", action=action, status="DENY", data=_("Unknown action"))  # fmt: skip

    return f"load_instance: unknown action {action} for {classname}:{keyname}"


# ---------------------------------------------------------------------
# CSV File to DATA
# ---------------------------------------------------------------------
def load_file_csv(
    file=None,
    filename=None,
    encoding=None,
    csv_delimiter=None,
    first=1,
    last=0,
    no_multi=False,
):
    """
    A pure CSV-shape -> list-of-dict reader, same standing as load_file_yaml()/
    load_file_json(): no Schema access, no classname/keyname resolution beyond what
    the file itself provides. classname comes only from a per-row "schema" column
    (renamed to "classname"); keyname comes only from a per-row "keyname" column
    (csv.DictReader already puts it there). Neither is required here - a row
    missing either is still returned as-is; resolving/rejecting/forcing a classname
    on the whole batch (force_schema=) is load_broker()'s job, not this function's.

    encoding / csv_delimiter = fallback values (defaults: utf-8 / CSV_DELIMITER
    configuration).

    Multi-valued cells: like cavctl (which has no server-side schema access either),
    any cell containing "^" is auto-split into a list. Set no_multi=True to disable
    this and keep "^" verbatim.

    output: (datalist, err)
        datalist: list of dict = [ {} , {}, {} ... ] , or None on error
        err: None on success, else an error string
    {
        classname: _home|user|group|role|_permisison|_schema|_enumerate|_dataview|_pipeline| ...
            _sirene_category|_sirene_public|_sirene_template
            CLASSNAME
        keyname: my key
        displayname: "a nice display name"
        is_enabled:
        (...)
    }
    """

    if not csv_delimiter:
        csv_delimiter = get_configuration(appname="home", keyname="CSV_DELIMITER")

    if not encoding:
        encoding = "utf-8"

    opened = None
    if file:
        csv_source = file
        source_label = "file"
    else:
        if not filename or not os.path.isfile(filename):
            return None, f"not a file ({filename})"
        opened = open(filename, encoding=encoding, newline="")
        csv_source = opened
        source_label = filename

    datalist = []
    cursor = 0

    try:
        csv_reader = csv.DictReader(csv_source, delimiter=csv_delimiter)

        for entry in csv_reader:
            cursor += 1
            if cursor < first:
                continue
            if last > 0 and cursor > last:
                break

            # classname: per-row "schema" column only (rename to "classname")
            row_schema = entry.pop("schema", None)
            if row_schema:
                entry["classname"] = row_schema

            # multi-value split ("^") - auto-detected, no schema access needed
            if not no_multi:
                for fieldname, value in list(entry.items()):
                    if isinstance(value, str) and "^" in value:
                        entry[fieldname] = [v.strip() for v in value.split("^")]

            datalist.append(entry)
    except UnicodeDecodeError as e:
        return None, f"encoding error in {source_label} : {e}"
    finally:
        if opened:
            opened.close()

    return datalist, None


# ---------------------------------------------------------------------
# JSON file to DATA
# ---------------------------------------------------------------------
def load_file_json(file=None, filename=None, first=1, last=0):
    """first/last: 1-based row range, applied after the full file is parsed - JSON
    has no per-row streaming like load_file_csv(), so the whole document is always
    read; unlike last=0 (no limit), first defaults to 1 (no skip).

    output: (data, err)
        data: list of dict, or None on error
        err: None on success, else an error string
    """

    data = []

    # from file
    if file:
        try:
            data = json.load(file)
        except Exception as e:
            return None, f"JSON syntax error in file : {e}"

    # from filename
    else:
        if not filename:
            return None, "missing filename"

        if not os.path.isfile(filename):
            return None, f"not a file ({filename})"

        with open(filename) as file:
            try:
                data = json.load(file)
            except Exception as e:
                return None, f"JSON syntax error in {filename} : {e}"

    if type(data) is not list:
        return None, f"invalid JSON, not a LIST ({filename})"

    if first > 1 or last > 0:
        data = data[first - 1 : last if last > 0 else None]

    return data, None


# ---------------------------------------------------------------------
# YAML File to DATA
# ---------------------------------------------------------------------
def load_file_yaml(file=None, filename=None, first=1, last=0):
    """first/last: 1-based row range, applied after the full file is parsed - YAML
    has no per-row streaming like load_file_csv(), so the whole document is always
    read; unlike last=0 (no limit), first defaults to 1 (no skip).

    output: (data, err)
        data: list of dict, or None on error
        err: None on success, else an error string
    """

    data = []

    # from file
    if file:
        try:
            data = yaml.load(file, Loader=yaml.SafeLoader)
        except Exception as e:
            return None, f"YAML syntax error in file : {e}"

    # from filename
    else:
        if not filename:
            return None, "missing filename"

        if not os.path.isfile(filename):
            return None, f"not a file ({filename})"

        with open(filename) as f:
            try:
                data = yaml.load(f, Loader=yaml.SafeLoader)
            except Exception as e:
                return None, f"YAML syntax error in {filename} : {e}"

    if type(data) is not list:
        return None, f"invalid YAML, not a LIST ({filename})"

    if first > 1 or last > 0:
        data = data[first - 1 : last if last > 0 else None]

    return data, None


# ---------------------------------------------------------------------
# Shared import loop : list-of-dict -> load_broker, row by row
# ---------------------------------------------------------------------
def run_import_batch(
    datalist,
    aaa,
    pipeline=None,
    force_action=None,
    force_schema=None,
    max_failure=None,
    batch_size=None,
    on_batch=None,
):
    """
    Per-row import loop shared by the synchronous Import Tool view
    (app_data/views_importer.py) and the async task_import Celery task
    (app_data/tasks.py), so their behavior (abort-on-max-failure, per-row
    error collection) can't drift apart between sync/async or between
    CSV/YAML/JSON the way it previously did.

    Aborts once count_ko > max_failure (max_failure=None means never abort
    on failure count).

    on_batch(count_done, total, count_ok, count_ko): optional, called every
    batch_size rows if both are given; return "abort" to stop the loop early
    (the result's aborted=True) - lets a caller check is_aborted()/report
    update_progress() periodically without this module importing
    task_manager.py itself.

    OUT: {count_ok, count_ko, errors, aborted, total}
    """

    count_ok = 0
    count_ko = 0
    errors = []
    aborted = False
    total = len(datalist)

    for i, datadict in enumerate(datalist, start=1):
        if max_failure is not None and count_ko > max_failure:
            aborted = True
            break

        if pipeline:
            status = pipeline.apply(datadict)
            if status == "discard":
                continue

        result = load_broker(
            [datadict], aaa=aaa, force_action=force_action, force_schema=force_schema
        )
        if result.get("errors"):
            count_ko += 1
            errors.extend(result["errors"])
        else:
            count_ok += 1

        if on_batch and batch_size and i % batch_size == 0:
            if on_batch(i, total, count_ok, count_ko) == "abort":
                aborted = True
                break

    return {
        "count_ok": count_ok,
        "count_ko": count_ko,
        "errors": errors,
        "aborted": aborted,
        "total": total,
    }
