# Released under the MIT License. See LICENSE for details.
#
"""Functionality for editing text strings.

This abstracts native edit dialogs as well as ones implemented via our
own ui toolkits.
"""

import time
import logging
import weakref
from enum import Enum
from typing import TYPE_CHECKING, final

from efro.util import empty_weakref

import _babase

if TYPE_CHECKING:
    pass


class StringEditKind(Enum):
    """Semantic kind for a string being edited.

    Platform edit UIs can use this to tailor presentation: keyboard
    action buttons (send vs done), title/button visibility,
    suggestion behavior, etc. Purely a presentation hint; has no
    effect on the resulting value.
    """

    #: Generic text edit; no special treatment.
    DEFAULT = 'default'

    #: A chat message; edit UIs may show a 'send' action and minimize
    #: chrome around the field.
    CHAT = 'chat'

    #: Source code or a command line. Edit UIs should turn off
    #: autocorrection, autocapitalization and smart punctuation, all of
    #: which corrupt code (smart quotes alone are a syntax error), and
    #: should treat their action key as 'run': a commit from it carries
    #: through to the target's return-press behavior, while a commit
    #: from tapping away just fills the value in.
    CODE = 'code'


class StringEditSubsystem:
    """Full string-edit state for the app.

    Access the single shared instance of this class via the
    :attr:`~babase.App.stringedit` attr on the :class:`~babase.App`
    class.
    """

    def __init__(self) -> None:
        self.active_adapter = empty_weakref(StringEditAdapter)


class StringEditAdapter:
    """Represents a string editing operation on some object.

    Editable objects such as text widgets or in-app-consoles can
    subclass this to make their contents editable on all platforms.

    There can only be one string-edit at a time for the app. New
    string-edits will attempt to register themselves as the globally
    active one in their constructor, but this may not succeed. If
    :meth:`can_be_replaced()` returns ``True`` for an adapter
    immediately after creating it, that means it was not able to set
    itself as the global one.
    """

    def __init__(
        self,
        description: str,
        initial_text: str,
        max_length: int | None,
        screen_space_center: tuple[float, float] | None,
        *,
        is_password: bool = False,
        kind: StringEditKind = StringEditKind.DEFAULT,
    ) -> None:
        if not _babase.in_logic_thread():
            raise RuntimeError('This must be called from the logic thread.')

        self.create_time = time.monotonic()

        # Note: these attr names are hard-coded in C++ code so don't
        # change them willy-nilly.
        self.description = description
        self.initial_text = initial_text
        self.max_length = max_length
        self.screen_space_center = screen_space_center
        # Whether the platform editor should mask input (password entry).
        self.is_password = is_password
        # Semantic kind hint for platform edit UIs.
        self.kind = kind

        # Attempt to register ourself as the active edit.
        subsys = _babase.app.stringedit
        current_edit = subsys.active_adapter()
        if current_edit is None or current_edit.can_be_replaced():
            subsys.active_adapter = weakref.ref(self)

    @final
    def can_be_replaced(self) -> bool:
        """Return whether this adapter can be replaced by a new one.

        This is mainly a safeguard to allow adapters whose drivers have
        gone away without calling :meth:`apply` or :meth:`cancel` to
        time out and be replaced with new ones.
        """
        if not _babase.in_logic_thread():
            raise RuntimeError('This must be called from the logic thread.')

        # Allow ourself to be replaced after a bit.
        if time.monotonic() - self.create_time > 5.0:
            if _babase.do_once():
                logging.warning(
                    'StringEditAdapter can_be_replaced() check for %s'
                    ' yielding True due to timeout; ideally this should'
                    ' not be possible as the StringEditAdapter driver'
                    ' should be blocking anything else from kicking off'
                    ' new edits.',
                    self,
                )
            return True

        # We also are always considered replaceable if we're not the
        # active global adapter.
        current_edit = _babase.app.stringedit.active_adapter()
        if current_edit is not self:
            return True

        return False

    @final
    def apply(self, new_text: str, submit: bool = False) -> None:
        """Should be called by the owner when editing is complete.

        Submit means the commit came from an enter-equivalent gesture
        (a keyboard send/done action key or the like) and should also
        trigger the edit target's return-press behavior, matching
        desktop inline editing where enter does both. A plain apply
        just stores the value.

        Note that in some cases this call may be a no-op (such as if
        this adapter is no longer the globally active one).
        """
        if not _babase.in_logic_thread():
            raise RuntimeError('This must be called from the logic thread.')

        # Make sure whoever is feeding this adapter is honoring max-length.
        if self.max_length is not None and len(new_text) > self.max_length:
            logging.warning(
                'apply() on %s was passed a string of length %d,'
                ' but adapter max_length is %d; this should not happen'
                ' (will truncate).',
                self,
                len(new_text),
                self.max_length,
                stack_info=True,
            )
            new_text = new_text[: self.max_length]

        self._do_apply(new_text)
        if submit:
            self._do_submit()

    @final
    def cancel(self) -> None:
        """Should be called by the owner when editing is cancelled."""
        if not _babase.in_logic_thread():
            raise RuntimeError('This must be called from the logic thread.')
        self._do_cancel()

    def _do_apply(self, new_text: str) -> None:
        """Should be overridden by subclasses to handle apply.

        Will always be called in the logic thread.
        """
        raise NotImplementedError('Subclasses must override this.')

    def _do_cancel(self) -> None:
        """Should be overridden by subclasses to handle cancel.

        Will always be called in the logic thread.
        """
        raise NotImplementedError('Subclasses must override this.')

    def _do_submit(self) -> None:
        """Trigger the edit target's return-press behavior, if any.

        Called after :meth:`_do_apply` for submit-style applies.
        Optional override; the default does nothing. Will always be
        called in the logic thread.
        """
