# -*- coding: utf-8 -*-
"""
Public SDK for verb authors. Import game primitives from here:

    from moo.sdk import context, lookup, create, NoSuchObjectError
"""

import logging
import warnings
from contextlib import contextmanager as _contextmanager
from typing import Union

from .core.code import ContextManager as _ContextManager
from .core.exceptions import (
    QuotaError,
    AmbiguousObjectError,
    UserError,
    NoSuchObjectError,
    NoSuchVerbError,
    NoSuchPropertyError,
)

__all__ = [
    "lookup",
    "create",
    "players",
    "connected_players",
    "write",
    "open_editor",
    "open_paginator",
    "invoke",
    "set_task_perms",
    "context",
    "moojson",
    "get_session_setting",
    "set_session_setting",
    "NoSuchObjectError",
    "NoSuchVerbError",
    "NoSuchPropertyError",
    "AmbiguousObjectError",
]

# Re-export moojson for verb use
from .core import moojson

_log = logging.getLogger(__name__)


def lookup(x: Union[int, str]):
    """
    Lookup an object globally by PK, name, or alias.

    :param x: lookup value
    :return: the result of the lookup
    :rtype: Object
    :raises NoSuchObjectError: when a result cannot be found
    """
    from django.db.models import Q
    from .core.models import Object

    if isinstance(x, int):
        return Object.objects.get(pk=x)
    elif isinstance(x, str):
        if x.startswith("$"):
            system = lookup(1)
            return system.get_property(name=x[1:])
        qs = Object.objects.filter(Q(name__iexact=x) | Q(aliases__alias__iexact=x)).distinct()
        if not qs:
            if context.parser:
                obj = context.parser.get_pronoun_object(x)
                if obj:
                    return obj
            raise NoSuchObjectError(x)
        return qs[0]
    else:
        raise ValueError(f"{x} is not a supported lookup value.")


def connected_players(within=None):
    """
    Return a list of player avatars whose ``last_connected_time`` property was
    updated within the given *within* window (default: 5 minutes).

    The ``last_connected_time`` value is precached into the session-level
    property cache on every returned Object so subsequent ``get_property``
    calls incur no extra queries.

    :param within: recency window; defaults to ``timedelta(minutes=5)``
    :type within: timedelta
    :return: Objects whose avatars have connected recently
    :rtype: list[Object]
    """
    from datetime import datetime, timedelta, timezone
    from .core.models.property import Property

    if within is None:
        within = timedelta(minutes=5)

    threshold = datetime.now(timezone.utc) - within

    # Single query: restrict to player avatars only and eagerly load the
    # origin Object to avoid per-row SELECT on prop.origin access.
    props = Property.objects.filter(name="last_connected_time", origin__player__isnull=False).select_related("origin")

    pcache = _ContextManager.get_prop_lookup_cache()
    result = []
    for prop in props:
        value = moojson.loads(prop.value)
        if pcache is not None:
            pcache[(prop.origin_id, "last_connected_time", True)] = value
        if value is not None and value >= threshold:
            result.append(prop.origin)

    return result


def players():
    """
    Return a list of all player avatar Objects.

    :return: Objects that are player avatars
    :rtype: list[Object]
    """
    from .core.models.auth import Player

    return [p.avatar for p in Player.objects.select_related("avatar").filter(avatar__isnull=False)]


def create(name, *a, **kw):
    """
    Creates and returns a new object whose parents are `parents` and whose owner is as described below.
    Provided `parents` are valid Objects with `derive` permission, otherwise :class:`.PermissionError` is
    raised. After the new object is created, its `initialize` verb, if any, is called with no arguments.

    The owner of the new object is either the programmer (if `owner` is not provided), or the provided owner,
    if the caller has permission to `entrust` the object.

    If the intended owner of the new object has a property named `ownership_quota` and the value of that
    property is an integer, then `create()` treats that value as a quota. If the quota is less than
    or equal to zero, then the quota is considered to be exhausted and `create()` raises :class:`.QuotaError` instead
    of creating an object. Otherwise, the quota is decremented and stored back into the `ownership_quota`
    property as a part of the creation of the new object.

    :param name: canonical name
    :type name: str
    :param owner: owner of the Object being created
    :type owner: Object
    :param location: where to create the Object
    :type location: Object
    :param parents: a list of parents for the Object
    :type parents: list[Object]
    :param obvious: whether the object appears in room contents listings (default False)
    :type obvious: bool
    :return: the new object
    :rtype: Object
    :raises PermissionError: if the caller is not allowed to `derive` from the parent
    :raises QuotaError: if the caller has a quota and it has been exceeded
    """
    from .core.models.object import Object, Property

    _SYSTEM_KEY = "__system_object__"
    cache = _ContextManager.get_perm_cache()
    if cache is not None and _SYSTEM_KEY in cache:
        system = cache[_SYSTEM_KEY]
    else:
        system = Object.objects.get(pk=1)
        if cache is not None:
            cache[_SYSTEM_KEY] = system
    default_parents = [system.root_class] if system.has_property("root_class") else []
    if context.caller:
        try:
            quota = context.caller.get_property("ownership_quota", recurse=False)
            if quota > 0:
                context.caller.set_property("ownership_quota", quota - 1)
            else:
                raise QuotaError(f"{context.caller} has run out of quota.")
        except NoSuchPropertyError:
            pass
        if "owner" not in kw:
            kw["owner"] = context.caller
    if "location" not in kw and "owner" in kw:
        kw["location"] = kw["owner"].location
    parents = kw.pop("parents", default_parents)
    obj = Object.objects.create(name=name, *a, **kw)
    if parents:
        obj.parents.add(*parents)
    if obj.has_verb("initialize"):
        invoke(obj.get_verb("initialize"))
    return obj


def write(obj, message):
    """
    Send an asynchronous message to the user.

    :param obj: the Object to write to
    :type obj: Object
    :param message: any pickle-able object
    :type message: Any
    """
    from moo.core import _publish_to_player

    if context.caller and not context.caller.is_wizard():
        raise UserError("Only verbs owned by wizards can write to the console.")
    _publish_to_player(obj, message)


def open_editor(obj, initial_content: str, callback_verb, *args, content_type: str = "text", title: str | None = None):
    """
    Request the connected SSH client to open a full-screen text editor.
    When the user saves, the edited text is passed to callback_verb as args[0],
    followed by any extra positional arguments supplied here.
    If the user cancels, the callback is not invoked.

    :param obj: the player Object whose client should open the editor
    :param initial_content: text to pre-populate the editor buffer
    :param callback_verb: Verb to invoke with the edited text as args[0]
    :param args: additional arguments forwarded to the callback verb as args[1:]
    :param content_type: "python", "json", or "text" (default); controls syntax highlighting
    """
    from moo.core import _publish_to_player

    if content_type not in ("python", "json", "text"):
        raise UserError(f"content_type must be 'python', 'json', or 'text', not {content_type!r}.")
    if context.caller and not context.caller.is_wizard():
        raise UserError("Only verbs owned by wizards can open the editor.")
    _publish_to_player(
        obj,
        {
            "event": "editor",
            "content": initial_content,
            "content_type": content_type,
            "title": title,
            "args": list(args),
            "callback_this_id": callback_verb._invoked_object.pk,  # pylint: disable=protected-access
            "callback_verb_name": callback_verb._invoked_name,  # pylint: disable=protected-access
            "caller_id": context.caller.pk,
            "player_id": (context.player or context.caller).pk,
        },
    )


def open_paginator(obj, content: str, content_type: str = "text"):
    """
    Request the connected SSH client to open a full-screen read-only paginator.
    The user can scroll through the content and press Q to quit.

    :param obj: the player Object whose client should open the paginator
    :param content: text to display
    :param content_type: "python", "json", or "text" (default); controls syntax highlighting
    """
    from moo.core import _publish_to_player

    if content_type not in ("python", "json", "text"):
        raise UserError(f"content_type must be 'python', 'json', or 'text', not {content_type!r}.")
    if context.caller and not context.caller.is_wizard():
        raise UserError("Only verbs owned by wizards can open the paginator.")
    _publish_to_player(
        obj,
        {
            "event": "paginator",
            "content": content,
            "content_type": content_type,
        },
    )


def invoke(*args, verb=None, callback=None, delay: int = 0, periodic: bool = False, cron: str = None, **kwargs):
    """
    Asynchronously execute a Verb, optionally returning the result to another Verb.
    This is often a better alternative than using `__call__`-syntax to invoke
    a verb directly, since Verbs invoked this way will each have their own timeout.

    :param verb: the Verb to execute
    :type verb: Verb
    :param callback: an optional callback Verb to receive the result
    :type callback: Verb
    :param delay: seconds to wait before executing, cannot be used with `cron`
    :param periodic: should this task continue to repeat? cannot be used with `cron`
    :param cron: a crontab expression to schedule Verb execution
    :param args: positional arguments for the Verb, if any
    :param kwargs: keyword arguments for the Verb, if any
    :returns: a :class:`.PeriodicTask` instance or `None` if the task is a one-shot
    :rtype: Optional[:class:`.PeriodicTask`]
    """
    if (periodic or cron) and context.caller and not context.caller.is_wizard():
        raise UserError("Only verbs owned by wizards can create persistent scheduled tasks.")
    if verb is not None and context.caller:
        exec_obj = (
            verb._invoked_object  # pylint: disable=protected-access
            if verb._invoked_object is not None  # pylint: disable=protected-access
            else verb.origin
        )
        exec_obj.can_caller("execute", verb)

    from django_celery_beat.models import CrontabSchedule, IntervalSchedule, PeriodicTask
    from moo.core import tasks

    kwargs.update(
        dict(
            caller_id=context.caller.pk,
            player_id=context.player.pk if context.player else None,
            this_id=verb._invoked_object.pk,  # pylint: disable=protected-access
            verb_name=verb._invoked_name,  # pylint: disable=protected-access
            callback_this_id=callback._invoked_object.pk if callback else None,  # pylint: disable=protected-access
            callback_verb_name=callback._invoked_name if callback else None,  # pylint: disable=protected-access
        )
    )
    if delay and periodic:
        schedule, _ = IntervalSchedule.objects.get_or_create(
            every=delay,
            period=IntervalSchedule.SECONDS,
        )
        return PeriodicTask.objects.create(
            interval=schedule,
            description=f"{context.caller.pk}:{verb}",
            task="moo.core.tasks.invoke_verb",
            args=args,
            kwargs=kwargs,
        )
    elif cron:
        cronparts = cron.split()
        schedule, _ = CrontabSchedule.objects.get_or_create(
            minute=cronparts[0],
            hour=cronparts[1],
            day_of_week=cronparts[2],
            day_of_month=cronparts[3],
            month_of_year=cronparts[4],
        )
        return PeriodicTask.objects.create(
            interval=schedule,
            description=f"{context.caller.pk}:{verb}",
            task="moo.core.tasks.invoke_verb",
            args=args,
            kwargs=kwargs,
        )
    else:
        tasks.invoke_verb.apply_async(args, kwargs, countdown=delay)
        return None


@_contextmanager
def set_task_perms(who):
    """
    Set the task permissions to those of `who` for the duration of the with-block.
    :param who: the Object whose permissions to assume
    :type who: Object
    """
    if context.caller and not context.caller.is_wizard():
        raise UserError("Only verbs owned by wizards can modify the task permissions..")

    if not _ContextManager.is_active() or who is None:
        yield
        return
    _ContextManager.override_caller(who)
    try:
        yield
    finally:
        _ContextManager.pop_caller()


def moo_eval(code_string: str):
    """
    Evaluate arbitrary Python code in the RestrictedPython sandbox.

    The code runs with the same environment as verb code, with standard
    verb variables (this, _, context) automatically available.

    :param code_string: Python code to evaluate
    :return: The result of the evaluation
    """
    from moo.core.code import get_default_globals, get_restricted_environment
    from RestrictedPython import compile_restricted
    import ast

    # Build the execution environment
    globals_dict = get_default_globals()
    globals_dict.update(get_restricted_environment("@eval", context.writer))

    # Add standard verb variables to locals
    locals_dict = {
        "this": context.player,
        "passthrough": lambda: None,
        "_": lookup(1),
    }

    # Try to evaluate as an expression first (for REPL-like behavior)
    try:
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", category=SyntaxWarning)
            compiled = compile_restricted(code_string, "<@eval>", "eval")
        return eval(compiled, globals_dict, locals_dict)  # pylint: disable=eval-used
    except SyntaxError:
        # If it's not a valid expression, compile as statements
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", category=SyntaxWarning)
            compiled = compile_restricted(code_string, "<@eval>", "exec")

        # Parse the code to check if the last statement is an expression
        try:
            tree = ast.parse(code_string, mode="exec")
            if tree.body and isinstance(tree.body[-1], ast.Expr):
                # Last statement is an expression - evaluate it and return
                # Execute all but the last statement
                if len(tree.body) > 1:
                    with warnings.catch_warnings():
                        warnings.simplefilter("ignore", category=SyntaxWarning)
                        exec_compiled = compile_restricted(
                            (
                                ast.unparse(tree.body[:-1][0])
                                if len(tree.body) == 2
                                else "\n".join(ast.unparse(stmt) for stmt in tree.body[:-1])
                            ),
                            "<@eval>",
                            "exec",
                        )
                    exec(exec_compiled, globals_dict, locals_dict)  # pylint: disable=exec-used

                # Now evaluate and return the last expression
                last_expr_code = ast.unparse(tree.body[-1].value)
                with warnings.catch_warnings():
                    warnings.simplefilter("ignore", category=SyntaxWarning)
                    expr_compiled = compile_restricted(last_expr_code, "<@eval>", "eval")
                return eval(expr_compiled, globals_dict, locals_dict)  # pylint: disable=eval-used
        except Exception:  # pylint: disable=broad-except
            pass  # Fall back to just executing

        # Execute the code (no return value)
        exec(compiled, globals_dict, locals_dict)  # pylint: disable=exec-used
        return None


class _Context:
    """
    This wrapper class makes it easy to use a number of contextvars.
    """

    class descriptor:
        """
        Used to perform dynamic lookups of contextvars.

        Defined as a data descriptor (implements both __get__ and __set__) so that
        Python's attribute lookup always invokes __get__ and never allows an instance
        attribute to shadow it.  Verb code must not be able to overwrite context.caller
        (or any other context attribute) with a forged object.
        """

        def __init__(self, name):
            self.name = name

        def __get__(self, obj, objtype=None):
            return _ContextManager.get(self.name)

        def __set__(self, obj, value):
            raise AttributeError("context attributes are read-only")

        def __delete__(self, obj):
            raise AttributeError("context attributes are read-only")

    def __setattr__(self, name, value):
        raise AttributeError("context attributes are read-only")

    caller = descriptor("caller")  # Code runs with the permission of this object
    player = descriptor("player")  # This object that originally invoked this session, defaults to original caller
    writer = descriptor("writer")  # A callable that will print to the player's console
    parser = descriptor("parser")
    task_id = descriptor("task_id")  # The current task ID
    task_time = descriptor("task_time")  # TaskTime(elapsed, time_limit, remaining) for the current task
    caller_stack = descriptor("caller_stack")  # A stack of callers, with the current caller at the end


context = _Context()


# Session settings for PREFIX/SUFFIX/QUIET commands
def get_session_setting(key, default=None):
    """
    Get a session-specific output setting for the current player.

    Session settings are stored per-user and cleared on disconnect.
    Used by PREFIX, SUFFIX, and QUIET commands for machine-readable output.

    :param key: setting name ('output_prefix', 'output_suffix', 'quiet_mode', 'color_system')
    :param default: value to return if setting is not found
    :return: setting value or default
    """
    from .shell import prompt as prompt_module

    player = context.player
    if not player:
        return default

    user_pk = player.owner.pk if player.owner else None
    if user_pk is None:
        return default

    settings = prompt_module._session_settings.get(user_pk, {})  # pylint: disable=protected-access
    return settings.get(key, default)


def set_session_setting(key, value):
    """
    Set a session-specific output setting for the current player.

    Session settings are stored per-user and cleared on disconnect.
    Used by PREFIX, SUFFIX, and QUIET commands for machine-readable output.

    Publishes a ``session_setting`` event to the player's Kombu message queue,
    which the SSH server's ``process_messages()`` loop picks up and applies to
    its own in-process registry. This bridges the Celery worker / SSH server
    process boundary.

    :param key: setting name ('output_prefix', 'output_suffix', 'quiet_mode', 'color_system')
    :param value: setting value
    """
    from moo.core import _publish_to_player

    player = context.player
    if not player:
        return
    _publish_to_player(player, {"event": "session_setting", "key": key, "value": value})


def boot_player(obj):
    """

    Disconnect the given player from the MOO server.

    Publishes a ``disconnect`` event to the player's Kombu message queue,
    which the SSH server's ``process_messages()`` loop picks up and exits cleanly.

    Permission: the caller must be the player being booted, or a wizard.

    :param obj: the player Object to disconnect
    """
    from moo.core import _publish_to_player

    caller = context.caller
    if caller and caller != obj and not caller.is_wizard():
        raise UserError("Only wizards can boot other players.")
    _publish_to_player(obj, {"event": "disconnect"})
