# (c) cavaliba.com - home - factory.py
# factory reset : empty the whole database and re-provision from builtin/

import logging
import os
import secrets

from django.conf import settings
from django.contrib.sessions.models import Session
from django.core.cache import caches

import app_home.cache as cache
from app_cmt.models import CMTDay, CMTHour, CMTRaw
from app_data.loader import load_broker, load_file_csv, load_file_json, load_file_yaml
from app_data.models import (
    DataClass,
    DataEAV,
    DataFile,
    DataInstance,
    DataRegistry,
    DataRevision,
    DataSchema,
    DataTask,
)
from app_data.permissions import permission_all_keynames
from app_data.registry import registry_get_key
from app_home.migrator import cavaliba_start
from app_home.models import CavalibaAPIStat, CavalibaConfiguration, CavalibaLog
from app_sirene.models import SMSJournal
from app_user.models import SirenePermission

logger = logging.getLogger(__name__)

# source/builtin/demo - the optional demo dataset, loaded on request after the
# bootstrap (patchable in tests)
DEMO_FOLDER = os.path.join(os.path.dirname(__file__), "..", "builtin", "demo")

# usernames restore_operator() never applies to: the builtin admin account (the
# bootstrap re-provisions it itself) and the pseudo-usernames of non-web callers
OPERATOR_SKIP = ("", "-", "unknown", "admin", "cron", "factory_reset")


# Every cavaliba-owned table, in a deliberate order (children first). These
# models carry no ForeignKey at all today, so the order is informational - it
# stays correct if a relation is added later.
# Django-internal tables are NOT listed on purpose: auth_user, auth_group,
# auth_permission, django_content_type, django_admin_log and above all
# django_migrations survive a factory reset (wiping django_migrations would
# make Django believe the schema was never migrated).
FACTORY_RESET_MODELS = [
    ("data_cache", DataEAV),
    ("data_revision", DataRevision),
    ("data_file", DataFile),
    ("data_task", DataTask),
    ("data_instance", DataInstance),
    ("data_field", DataSchema),
    ("data_schema", DataClass),
    ("data_registry", DataRegistry),
    ("log", CavalibaLog),
    ("api_stat", CavalibaAPIStat),
    ("configuration", CavalibaConfiguration),
    ("iam_permission", SirenePermission),
    ("sms_journal", SMSJournal),
    ("cmt_raw", CMTRaw),
    ("cmt_hour", CMTHour),
    ("cmt_day", CMTDay),
    # cache-based sessions are already dropped by the cache clear ; a
    # DB-session deployment must not keep live sessions either
    ("django_session", Session),
]


def factory_reset_available():
    """True when the factory reset feature is usable, i.e. the admin account
    password (CAVALIBA_ADMIN_PASSWORD env var) is set and not empty. Without it
    there is no second factor, so the feature stays disabled - that is also how a
    deployment forbids factory resets altogether. Note that the same env var, when
    unset, already locks the Django admin account (migrator.sync_admin_password).
    A database password is deliberately NOT used: a sqlite instance has none."""

    return bool(settings.CAVALIBA_ADMIN_PASSWORD)


def factory_reset_password_ok(password=None):
    """Constant-time check of the submitted password against the admin account
    password (CAVALIBA_ADMIN_PASSWORD env var). False when the env var is
    unset/empty (feature disabled) or when the password doesn't match."""

    expected = settings.CAVALIBA_ADMIN_PASSWORD
    if not expected:
        return False
    if not password:
        return False

    return secrets.compare_digest(str(password), str(expected))


def factory_folders():
    """The on-disk folders a factory reset empties: uploaded file blobs, generated
    exports, and the mail/sms spools. Returns a dict label -> path."""

    return {
        "filestore": str(settings.CAVALIBA_FILESTORE),
        "export": str(settings.CAVALIBA_EXPORT_FOLDER),
        "mail": str(settings.CAVALIBA_MAIL_FOLDER),
        "sms": str(settings.CAVALIBA_SMS_FOLDER),
    }


def wipe_folder(path=None):
    """Delete every regular file directly inside `path`. .gitignore is kept (it
    keeps the otherwise-empty folder tracked in git) and sub-folders are left
    alone. A missing folder is not an error. Returns (removed_count, errors)."""

    removed = 0
    errors = []

    if not path or not os.path.isdir(path):
        return removed, errors

    try:
        entries = os.listdir(path)
    except OSError as exc:
        return removed, [f"cannot list {path}: {exc}"]

    for name in entries:
        if name == ".gitignore":
            continue
        fullpath = os.path.join(path, name)
        if not os.path.isfile(fullpath):
            continue
        try:
            os.remove(fullpath)
            removed += 1
        except OSError as exc:
            errors.append(f"cannot delete {fullpath}: {exc}")

    return removed, errors


def wipe_files():
    """Empty every folder of factory_folders(). Returns (files_dict, errors) where
    files_dict maps the folder label to the number of files removed."""

    files = {}
    errors = []

    for label, path in factory_folders().items():
        removed, errs = wipe_folder(path)
        files[label] = removed
        errors.extend(errs)

    return files, errors


def wipe_database():
    """Delete every row of every cavaliba-owned table (FACTORY_RESET_MODELS).
    Django-internal tables are untouched. Returns a dict table -> deleted count."""

    deleted = {}

    for label, model in FACTORY_RESET_MODELS:
        count = model.objects.all().count()
        model.objects.all().delete()
        deleted[label] = count
        logger.info(f"factory_reset - {label}: {count} row(s) deleted")

    return deleted


def wipe_cache():
    """Empty the in-memory caches and every configured django cache backend
    (redis or DB table). This also destroys every session, including the
    session of the operator running the reset."""

    for alias in settings.CACHES:
        try:
            caches[alias].clear()
        except Exception as exc:
            logger.error(f"factory_reset - cannot clear cache {alias}: {exc}")

    cache.clear()
    cache.init()


def load_demo(folder=None):
    """Load source/builtin/demo/ (or `folder`) the way `cavaliba load builtin/demo/`
    does: every regular file of the folder, sorted by name, one pass. Returns a
    dict with the file/object counts and any per-file error."""

    demodir = folder or DEMO_FOLDER
    report = {"folder": demodir, "files": 0, "objects": 0, "errors": []}

    if not os.path.isdir(demodir):
        report["errors"].append(f"no demo folder: {demodir}")
        return report

    filenames = sorted(
        item.path for item in os.scandir(demodir) if item.is_file(follow_symlinks=False)
    )

    aaa = {
        "perms": permission_all_keynames(),
        "username": "factory_reset",
        "sync_mode": False,
    }

    for filename in filenames:
        if filename.endswith(".csv"):
            datalist, err = load_file_csv(filename=filename)
        elif filename.endswith(".yml") or filename.endswith(".yaml"):
            datalist, err = load_file_yaml(filename=filename)
        elif filename.endswith(".json"):
            datalist, err = load_file_json(filename=filename)
        else:
            report["errors"].append(f"skip - unknown filetype: {filename}")
            continue

        if err:
            report["errors"].append(f"{os.path.basename(filename)}: {err}")
            continue

        if not datalist:
            report["errors"].append(f"{os.path.basename(filename)}: empty file")
            continue

        load_broker(datalist=datalist, aaa=aaa)
        report["files"] += 1
        report["objects"] += len(datalist)
        logger.info(f"factory_reset - demo loaded {filename} ({len(datalist)} objects)")

    return report


def restore_operator(username=None):
    """Put the account that ran the reset back, with the role_admin role.

    Only the builtin "admin" user comes back from builtin/init. Any other admin -
    typically an oauth2 (or local) account holding role_admin - would otherwise
    find an instance where its own user object no longer exists: locked out for
    good when AUTH_PROVISIONING is "visitor", or JIT-provisioned as a plain
    permission-less user otherwise, with no way back unless it happens to know the
    admin account password. Recreating the user and appending it to role_admin (a
    role it necessarily held to reach this page) guarantees it can sign in again
    with its admin rights.

    Only the identity is restored: keyname, displayname, is_enabled. Everything
    else (email, groups, other roles, custom fields) is gone with the rest of the
    data - this is a factory reset.

    Returns {"username":.., "restored": bool, "errors": [...]}."""

    from app_data.role import Role

    report = {"username": username or "", "restored": False, "errors": []}

    if not username or username in OPERATOR_SKIP or ":" in username:
        return report

    aaa = {"perms": permission_all_keynames(), "username": "factory_reset"}
    datalist = [
        {
            "classname": "user",
            "_action": "create",
            "keyname": username,
            "displayname": username,
            "is_enabled": True,
        },
        {
            "classname": "role",
            "_action": "append",
            "keyname": "role_admin",
            "users": [username],
        },
    ]
    reply = load_broker(datalist=datalist, aaa=aaa)
    report["errors"].extend(reply.get("errors", []))

    # verify, rather than trust the loader: a user left out of role_admin cannot
    # administer the instance it just reset
    role = Role.from_keyname(keyname="role_admin")
    field = role.fields.get("users") if role else None
    if field and username in field.value:
        report["restored"] = True
    else:
        report["errors"].append(f"could not grant role_admin to {username}")

    logger.warning(f"factory_reset - operator {username} restored={report['restored']}")

    return report


def factory_reset(aaa=None, with_demo=False, demo_folder=None):
    """Empty the whole cavaliba database (and the on-disk state it owns), then
    re-provision a fresh instance from source/builtin/init/ exactly like a first
    start does, and optionally load source/builtin/demo/.

    Django-internal tables survive (see FACTORY_RESET_MODELS), so local Django
    accounts still work, but every session is destroyed by the cache clear -
    including the caller's.

    Runs synchronously: with demo data this takes a while (~3800 objects).

    The caller's own account is recreated with role_admin afterwards unless it is
    the builtin "admin" (see restore_operator()).

    Returns a report dict:
        {"files": {label: count}, "deleted": {table: count}, "db_version": str,
         "demo": {...} or None, "operator": {...}, "errors": [...]}
    """

    report = {
        "files": {},
        "deleted": {},
        "db_version": "",
        "demo": None,
        "operator": None,
        "errors": [],
    }

    username = aaa.get("username", "-") if aaa else "-"
    logger.warning(f"factory_reset - START (user={username}, demo={with_demo})")

    # 1. on-disk state
    report["files"], errors = wipe_files()
    report["errors"].extend(errors)

    # 2. database
    report["deleted"] = wipe_database()

    # 3. caches - before bootstrap, so provisioning can't read a stale
    #    Schema()/Instance() of a schema that no longer exists
    wipe_cache()

    # 4. fresh bootstrap : DB_VERSION is gone, so cavaliba_start() runs the full
    #    builtin/init provisioning, then configuration sync, migrations up to the
    #    code version, apikey secret and admin password sync
    cavaliba_start()
    report["db_version"] = registry_get_key(key="DB_VERSION") or ""
    if report["db_version"] != settings.CAVALIBA_VERSION:
        report["errors"].append(
            f"bootstrap ended at db_version {report['db_version']} "
            f"instead of {settings.CAVALIBA_VERSION}"
        )

    # 5. demo data
    if with_demo:
        report["demo"] = load_demo(folder=demo_folder)
        report["errors"].extend(report["demo"]["errors"])

    # 6. the operator's own account - after the demo load, so a demo object of the
    #    same keyname cannot overwrite it or drop the role_admin grant
    report["operator"] = restore_operator(username)
    report["errors"].extend(report["operator"]["errors"])

    # 7. caches again - bootstrap and demo load filled them along the way
    wipe_cache()

    logger.warning(
        f"factory_reset - DONE (user={username}, db_version={report['db_version']}, "
        f"errors={len(report['errors'])})"
    )

    return report
