import inspect
import logging
from functools import cached_property

from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.db import models, router, transaction
from django.db.models import Q
from django.urls import reverse
from django.utils.translation import gettext_lazy as _

from core.choices import ManagedFileRootPathChoices
from core.models import ManagedFile
from extras.utils import is_script
from netbox.models.features import EventRulesMixin, JobsMixin, batch_delete_jobs
from utilities.querysets import RestrictedQuerySet

from .mixins import PythonModuleMixin

__all__ = (
    'Script',
    'ScriptModule',
)

logger = logging.getLogger('netbox.data_backends')


class Script(EventRulesMixin, JobsMixin):
    name = models.CharField(
        verbose_name=_('name'),
        max_length=79,  # Maximum length for a Python class name
        editable=False,
    )
    module = models.ForeignKey(
        to='extras.ScriptModule',
        on_delete=models.CASCADE,
        related_name='scripts',
        editable=False
    )
    is_executable = models.BooleanField(
        default=True,
        verbose_name=_('is executable'),
        editable=False
    )
    events = GenericRelation(
        'extras.EventRule',
        content_type_field='action_object_type',
        object_id_field='action_object_id'
    )

    def __str__(self):
        return self.name

    objects = RestrictedQuerySet.as_manager()

    class Meta:
        ordering = ('module', 'name')
        constraints = (
            models.UniqueConstraint(
                fields=('name', 'module'),
                name='extras_script_unique_name_module'
            ),
        )
        indexes = (
            models.Index(fields=('module', 'name')),  # Default ordering
        )
        verbose_name = _('script')
        verbose_name_plural = _('scripts')

    def get_absolute_url(self):
        return reverse('extras:script', args=[self.pk])

    @property
    def result(self):
        return self.jobs.all().order_by('-created').first()

    @cached_property
    def python_class(self):
        return self.module.module_scripts.get(self.name)

    def delete(self, soft_delete=False, **kwargs):
        if soft_delete and self.jobs.exists():
            self.is_executable = False
            self.save()
        else:
            super().delete(**kwargs)
            self.id = None


class ScriptModuleManager(models.Manager.from_queryset(RestrictedQuerySet)):

    def get_queryset(self):
        return super().get_queryset().filter(
            Q(file_root=ManagedFileRootPathChoices.SCRIPTS) | Q(file_root=ManagedFileRootPathChoices.REPORTS))


class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
    """
    Proxy model for script module files.
    """
    objects = ScriptModuleManager()
    error = None

    event_rules = GenericRelation(
        to='extras.EventRule',
        content_type_field='action_object_type',
        object_id_field='action_object_id',
        for_concrete_model=False
    )

    class Meta:
        proxy = True
        ordering = ('file_root', 'file_path')
        verbose_name = _('script module')
        verbose_name_plural = _('script modules')

    def get_absolute_url(self):
        return reverse('extras:script_list')

    def __str__(self):
        return self.python_name

    def delete(self, using=None, *args, **kwargs):
        # Job is imported here rather than at module level to avoid a circular import
        # (core.models.jobs -> core.signals -> extras.events -> extras.models -> this module).
        from core.models import Job

        # Deleting a ScriptModule cascades (via the Script.module FK) to its child Scripts, and
        # Django's collector would materialize every one of those Scripts' Jobs to delete them.
        # A module's scripts can accumulate thousands of jobs, exhausting memory. Batch-delete
        # the child Scripts' jobs up front, in a single queryset (no per-script loop), before
        # delegating to the cascade. The transaction rolls the job deletions back if the parent
        # delete fails; note it does not cover ManagedFile.delete removing the file from disk,
        # which happens before the DB delete and is not transactional. See #22812.
        using = using or router.db_for_write(self.__class__, instance=self)
        with transaction.atomic(using=using):
            script_type = ContentType.objects.get_for_model(Script, for_concrete_model=False)
            child_jobs = Job.objects.using(using).filter(
                object_type=script_type,
                object_id__in=self.scripts.values_list('pk', flat=True),
            )
            batch_delete_jobs(child_jobs)
            return super().delete(using, *args, **kwargs)
    delete.alters_data = True

    @property
    def ordered_scripts(self):
        script_objects = {s.name: s for s in self.scripts.all()}
        ordered = [
            script_objects.pop(sc) for sc in self.module_scripts.keys() if sc in script_objects
        ]
        ordered.extend(script_objects.values())
        return ordered

    @cached_property
    def module_scripts(self):

        def _get_name(cls):
            # For child objects in submodules use the full import path w/o the root module as the name
            return cls.full_name.split(".", maxsplit=1)[1]

        try:
            module = self.get_module()
        except Exception as e:
            self.error = e
            logger.error(f"Failed to load script: {self.python_name} error: {e}")
            module = None

        scripts = {}
        ordered = getattr(module, 'script_order', [])

        for cls in ordered:
            scripts[_get_name(cls)] = cls
        for name, cls in inspect.getmembers(module, is_script):
            if cls not in ordered:
                scripts[_get_name(cls)] = cls

        return scripts

    def sync_classes(self):
        """
        Syncs the file-based module to the database, adding and removing individual Script objects
        in the database as needed.
        """
        if self.id:
            db_classes = {
                script.name: script for script in self.scripts.all()
            }
        else:
            db_classes = {}

        db_classes_set = set(db_classes.keys())
        module_classes_set = set(self.module_scripts.keys())

        # remove any existing db classes if they are no longer in the file
        removed = db_classes_set - module_classes_set
        for name in removed:
            db_classes[name].delete(soft_delete=True)

        added = module_classes_set - db_classes_set
        for name in added:
            Script.objects.create(
                module=self,
                name=name,
                is_executable=True,
            )
    sync_classes.alters_data = True

    def sync_data(self):
        super().sync_data()
    sync_data.alters_data = True

    def save(self, *args, **kwargs):
        self.file_root = ManagedFileRootPathChoices.SCRIPTS
        super().save(*args, **kwargs)

        # Sync script classes after the module has been saved. This is the
        # single intended synchronization path for ScriptModule saves.
        self.sync_classes()
