# Copyright 2024 The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""
Context for database and permission operations.

This is currently based on ContextVar instead of asgiref.local.Local, because
the behaviour of Local is inconsistent across asgiref versions, and probably
buggy in asgiref 3.7+ (see https://github.com/django/asgiref/issues/473).

Ideally, this should eventually aligned with using the same
thread-local/task-local infrastructure that Django uses.

Note that when using threads, if a new thread is started, it will see the
application context reset to empty values: threads that need application
context values need to implement a way to inherit the caller's values.

Note that similar but more complicated gotchas would apply when
`django.urls.reverse` tries to check if `request.urlconf` was previously set to
a non-default value: threads and tasks that need reverse url resolution need to
be mindful of the inconsistencies in behaviour of the underlying
`asgiref.local.Local` implementation, and of asgiref issue #473.

See also https://code.djangoproject.com/ticket/35807
"""

from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar

from debusine.db.models import Scope, Workspace


class ContextConsistencyError(Exception):
    """Raised if an inconsistency is found when setting application context."""


class Context:
    """Storage for Debusine application context."""

    # https://github.com/django/asgiref/issues/473

    # Use slots to catch typos in functions that set context variables
    __slots__ = ["_scope", "_workspace"]

    def __init__(self) -> None:
        """Initialize the default values."""
        super().__init__()
        self._scope: ContextVar[Scope | None] = ContextVar(
            "scope", default=None
        )
        self._workspace: ContextVar[Workspace | None] = ContextVar(
            "workspace", default=None
        )

    @property
    def scope(self) -> Scope | None:
        """Get the current scope."""
        return self._scope.get()

    @scope.setter
    def scope(self, new_scope: Scope | None) -> None:
        """
        Set the current scope.

        If a workspace is currently set for a different scope, it will be set to
        None.
        """
        if new_scope is None:
            self._scope.set(None)
            self._workspace.set(None)
            return

        self._scope.set(new_scope)

        if (
            current_workspace := self.workspace
        ) is not None and current_workspace.scope != new_scope:
            self._workspace.set(None)

    @property
    def workspace(self) -> Workspace | None:
        """Get the current workspace."""
        return self._workspace.get()

    @workspace.setter
    def workspace(self, new_workspace: Workspace | None) -> None:
        """
        Set the current workspace.

        If the scope is not set, the scope will also be set using the
        workspace's scope.

        If the workspace belongs to a scope that is not the current scope,
        ContextConsistencyError is raised.
        """
        if new_workspace is None:
            self._workspace.set(None)
            return

        if (current_scope := self.scope) is None:
            self._scope.set(new_workspace.scope)
            self._workspace.set(new_workspace)
        elif current_scope == new_workspace.scope:
            self._workspace.set(new_workspace)
        else:
            raise ContextConsistencyError(
                f"workspace scope {new_workspace.scope.name!r}"
                f" does not match current scope {current_scope.name!r}"
            )

    def reset(self) -> None:
        """Reset the application context to default values."""
        self._scope.set(None)
        self._workspace.set(None)

    @contextmanager
    def local(self) -> Generator[None, None, None]:
        """Restore application context when the context manager ends."""
        orig_scope = self.scope
        orig_workspace = self.workspace
        try:
            yield
        finally:
            self._scope.set(orig_scope)
            self._workspace.set(orig_workspace)


context = Context()
