# Released under the MIT License. See LICENSE for details.
#
"""Account related functionality."""

import time
import hashlib
import logging
from functools import partial
from dataclasses import dataclass
from typing import TYPE_CHECKING, assert_never

from efro.error import CommunicationError
from efro.call import CallbackSet
from bacommon.login import LoginType

from babase._logging import accountlog, lifecyclelog, netlog
import _babase

if TYPE_CHECKING:
    from typing import Any, Callable

    import bacommon.cloud

    from babase import LangStr
    from babase._login import LoginAdapter, LoginInfo


class AccountV2Subsystem:
    """Subsystem for modern account handling in the app.

    Access the single shared instance of this class via the
    :attr:`~baplus.PlusAppSubsystem.accounts` attr on the
    :class:`~baplus.PlusAppSubsystem` class.
    """

    def __init__(self) -> None:
        assert _babase.in_logic_thread()

        from babase._login import LoginAdapterGPGS, LoginAdapterGameCenter

        # Register to be informed when connectivity changes.
        plus = _babase.app.plus
        self._connectivity_changed_cb = (
            None
            if plus is None
            else plus.cloud.on_connectivity_changed_callbacks.register(
                self._on_cloud_connectivity_changed
            )
        )

        # Whether or not everything related to an initial sign in (or
        # lack thereof) has completed. This includes things like
        # workspace syncing. Completion of this is what flips the app
        # into 'running' state.
        self._initial_sign_in_completed = False

        self._kicked_off_workspace_load = False

        self.login_adapters: dict[LoginType, LoginAdapter] = {}

        #: Whether Discord SDK support is compiled into this build. True
        #: means the Discord sign-in flow and reconnect-on-launch are
        #: usable; gate Discord-specific UI on this.
        self.discord_available: bool = _babase.discord_available()

        self._implicit_signed_in_adapter: LoginAdapter | None = None
        self._implicit_state_changed = False
        self._can_do_auto_sign_in = True
        self.on_primary_account_changed_callbacks: CallbackSet[
            Callable[[AccountV2Handle | None], None]
        ] = CallbackSet()

        # Request state per global-app-instance-id
        self._auth_requests: dict[str, _AuthRequest] = {}

        adapter: LoginAdapter
        if _babase.using_google_play_game_services():
            adapter = LoginAdapterGPGS()
            self.login_adapters[adapter.login_type] = adapter
        if _babase.using_game_center():
            adapter = LoginAdapterGameCenter()
            self.login_adapters[adapter.login_type] = adapter

    def on_app_loading(self) -> None:
        """Internal; Called at standard on_app_loading time.

        :meta private:
        """
        for adapter in self.login_adapters.values():
            adapter.on_app_loading()

    def have_primary_credentials(self) -> bool:
        """Are credentials currently set for the primary app account?

        Note that this does not mean these credentials have been checked
        for validity; only that they exist. If/when credentials are
        validated, the :attr:`primary` account handle will be set.
        """
        raise NotImplementedError()

    @property
    def primary(self) -> AccountV2Handle | None:
        """The primary account for the app, or None if not logged in."""
        return self.do_get_primary()

    def on_primary_account_changed(
        self, account: AccountV2Handle | None
    ) -> None:
        """Callback run after the primary account changes.

        Will be called with None on log-outs and when new credentials
        are set but have not yet been verified.

        :meta private:
        """
        assert _babase.in_logic_thread()

        # Blow away any outstanding auth-requests.
        self._auth_requests = {}

        # Inform the base layer of new names/etc.
        if account is not None:
            _babase.set_account_sign_in_state(True, account.tag)
        else:
            _babase.set_account_sign_in_state(False)

        # Fire any registered callbacks.
        for call in self.on_primary_account_changed_callbacks.getcalls():
            try:
                call(account)
            except Exception:
                logging.exception('Error in primary-account-changed callback.')

        # Currently don't do anything special on sign-outs.
        if account is None:
            return

        # If this new account has a workspace, update it and ask to be
        # informed when that process completes.
        if account.workspaceid is not None:
            assert account.workspacename is not None
            if (
                not self._initial_sign_in_completed
                and not self._kicked_off_workspace_load
            ):
                self._kicked_off_workspace_load = True
                _babase.app.workspaces.set_active_workspace(
                    account=account,
                    workspaceid=account.workspaceid,
                    workspacename=account.workspacename,
                    on_completed=self._on_set_active_workspace_completed,
                )
            else:
                # Don't activate workspaces if we've already told the
                # game that initial-log-in is done or if we've already
                # kicked off a workspace load.
                _babase.screenmessage(
                    f'\'{account.workspacename}\''
                    f' will be activated at next app launch.',
                    color=(1, 1, 0),
                )
                _babase.getsimplesound('error').play()
            return

        # Ok; no workspace to worry about; carry on.
        if not self._initial_sign_in_completed:
            lifecyclelog.debug('initial-sign-in path: account-no-workspace')
            self._initial_sign_in_completed = True
            _babase.app.on_initial_sign_in_complete()

    def on_active_logins_changed(self, logins: dict[LoginType, str]) -> None:
        """Called when logins for the active account change.

        :meta private:
        """
        for adapter in self.login_adapters.values():
            adapter.set_active_logins(logins)

    def on_implicit_sign_in(
        self, login_type: LoginType, login_id: str, display_name: str
    ) -> None:
        """An implicit sign-in happened (called by native layer).

        :meta private:
        """
        from babase._login import LoginAdapter

        assert _babase.in_logic_thread()

        with _babase.ContextRef.empty():
            self.login_adapters[login_type].set_implicit_login_state(
                LoginAdapter.ImplicitLoginState(
                    login_id=login_id, display_name=display_name
                )
            )

    def on_implicit_sign_out(self, login_type: LoginType) -> None:
        """An implicit sign-out happened (called by native layer).

        :meta private:
        """
        assert _babase.in_logic_thread()
        with _babase.ContextRef.empty():
            self.login_adapters[login_type].set_implicit_login_state(None)

    def on_no_initial_primary_account(self) -> None:
        """Internal; run if the app has no primary account after launch.

        Either this callback or on_primary_account_changed will be
        called within a few seconds of app launch; the app can move
        forward with the startup sequence at that point.

        :meta private:
        """
        if not self._initial_sign_in_completed:
            lifecyclelog.debug('initial-sign-in path: no-account')
            self._initial_sign_in_completed = True
            _babase.app.on_initial_sign_in_complete()

    def auth_request(
        self, global_app_instance_id: str
    ) -> None | tuple[bool, str, int | None]:
        """Start/process an auth request.

        Returns ``None`` while in flight, or ``(success, value,
        reason)`` where ``value`` is a token on success or error text
        on failure, and ``reason`` is an optional
        :class:`~bacommon.cloud.JoinRejectReason` int value
        accompanying a failure (the native layer renders a recognized
        reason as its own localized builtin string, falling back to
        the error text otherwise).
        """
        import bacommon.cloud

        assert _babase.in_logic_thread()
        plus = _babase.app.plus
        assert plus is not None

        now = time.monotonic()

        # If there are any expired ones, do a prune pass.
        if any(r.expire_time <= now for r in self._auth_requests.values()):
            self._auth_requests = {
                rid: r
                for rid, r in self._auth_requests.items()
                if r.expire_time > now
            }

        auth_request = self._auth_requests.get(global_app_instance_id)

        # If we find no attempt in progress, kick one off (or fail fast).
        if auth_request is None:
            if self.primary is None:
                return (
                    False,
                    'You must sign in to do this.',
                    bacommon.cloud.JoinRejectReason.MUST_SIGN_IN.value,
                )
        if (
            auth_request is None
            and plus.cloud.connected
            and self.primary is not None
        ):
            netlog.debug('Sending v2 auth request...')
            auth_request = self._auth_requests[global_app_instance_id] = (
                _AuthRequest(
                    expire_time=now + 10.0,
                    error=None,
                    token=None,
                    reason=None,
                )
            )
            with self.primary:
                plus.cloud.send_message_cb(
                    bacommon.cloud.AuthRequestMessage(global_app_instance_id),
                    on_response=partial(
                        self._on_auth_request_response, auth_request
                    ),
                )

        # If we found results, return them.
        if auth_request is None:
            return None
        if auth_request.error is not None:
            assert auth_request.token is None
            return (False, auth_request.error, auth_request.reason)
        if auth_request.token is not None:
            assert auth_request.error is None
            return (True, auth_request.token, None)
        # No error or token; its still in flight.
        return None

    def _on_auth_request_response(
        self,
        auth_request: _AuthRequest,
        response: bacommon.cloud.AuthRequestResponse | Exception,
    ) -> None:
        import bacommon.cloud

        assert _babase.in_logic_thread()

        assert auth_request.error is None
        assert auth_request.token is None

        if isinstance(response, Exception):
            auth_request.error = 'An error has occurred.'
            auth_request.reason = (
                bacommon.cloud.JoinRejectReason.AUTH_ERROR.value
            )
        else:
            netlog.debug(
                'Got V2 auth response with error %s and token %s.',
                response.error,
                response.token,
            )
            auth_request.error = response.error
            auth_request.token = response.token
            auth_request.reason = response.reason

            # Make sure this sticks around for long enough to complete
            # the connection.
            auth_request.expire_time = time.monotonic() + 10.0

    @staticmethod
    def _hashstr(val: str) -> str:
        md5 = hashlib.md5()
        md5.update(val.encode())
        return md5.hexdigest()

    def on_implicit_login_state_changed(
        self,
        login_type: LoginType,
        state: LoginAdapter.ImplicitLoginState | None,
    ) -> None:
        """Internal; Called when implicit login state changes.

        Login systems that tend to sign themselves in/out in the
        background are considered implicit. We may choose to honor or
        ignore their states, allowing the user to opt for other login
        types even if the default implicit one can't be explicitly
        logged out or otherwise controlled.

        :meta private:
        """
        from babase import builtinassets

        assert _babase.in_logic_thread()

        cfg = _babase.app.config
        cfgkey = 'ImplicitLoginStates'
        cfgdict = _babase.app.config.setdefault(cfgkey, {})

        # Store which (if any) adapter is currently implicitly signed
        # in. Making the assumption there will only ever be one implicit
        # adapter at a time; may need to revisit this logic if that
        # changes.
        prev_state = cfgdict.get(login_type.value)
        if state is None:
            self._implicit_signed_in_adapter = None
            new_state = cfgdict[login_type.value] = None
        else:
            self._implicit_signed_in_adapter = self.login_adapters[login_type]
            new_state = cfgdict[login_type.value] = self._hashstr(
                state.login_id
            )

            # Special case: if the user is already signed in but not
            # with this implicit login, let them know that the 'Welcome
            # back FOO' they likely just saw is not actually accurate.
            if (
                self.primary is not None
                and not self.login_adapters[login_type].is_back_end_active()
            ):
                service_str: LangStr | None
                if login_type is LoginType.GPGS:
                    service_str = builtinassets.strings.ui.google_play
                elif login_type is LoginType.GAME_CENTER:
                    # Note: Apparently Game Center is just called 'Game
                    # Center' in all languages. Can revisit if not true.
                    # https://developer.apple.com/forums/thread/725779
                    service_str = builtinassets.strings.ui.game_center
                elif login_type is LoginType.EMAIL:
                    # Not possible; just here for exhaustive coverage.
                    service_str = None
                elif login_type is LoginType.DISCORD:
                    # Not platform-implicit; can't fire here.
                    service_str = None
                else:
                    assert_never(login_type)
                if service_str is not None:
                    _babase.apptimer(
                        2.0,
                        partial(
                            _babase.screenmessage,
                            builtinassets.strings.account.not_using_account(
                                service=service_str
                            ),
                            (1, 0.5, 0),
                        ),
                    )

        cfg.commit()

        # We want to respond any time the implicit state changes;
        # generally this means the user has explicitly signed in/out or
        # switched accounts within that back-end.
        if prev_state != new_state:
            accountlog.debug(
                'Implicit state changed (%s -> %s);'
                ' will update app sign-in state accordingly.',
                prev_state,
                new_state,
            )
            self._implicit_state_changed = True

        # We may want to auto-sign-in based on this new state.
        self._update_auto_sign_in()

    def _on_cloud_connectivity_changed(self, connected: bool) -> None:
        """Should be called with cloud connectivity changes."""
        del connected  # Unused.
        assert _babase.in_logic_thread()

        # We may want to auto-sign-in based on this new state.
        self._update_auto_sign_in()

    def do_get_primary(self) -> AccountV2Handle | None:
        """Internal; should be overridden by subclass.

        :meta private:
        """
        raise NotImplementedError()

    def set_primary_credentials(self, credentials: str | None) -> None:
        """Set credentials for the primary app account.

        Once credentials are set, they will be verified in the cloud
        asynchronously. If verification is successful, the
        :attr:`primary` attr will be set to the resulting account.
        """
        raise NotImplementedError()

    def on_discord_auth_received(
        self, refresh_token: str | None, discord_user_id: str | None
    ) -> None:
        """Receive a Discord OAuth2 refresh token + user-id from native.

        Called whenever the Discord SDK obtains a new refresh token —
        on initial sign-in and on each subsequent token rotation during
        a successful ``RefreshToken`` call. Implementations should
        persist the pair atomically so a crash cannot leave us with a
        stale token.

        :meta private:
        """
        raise NotImplementedError()

    def _update_auto_sign_in(self) -> None:
        plus = _babase.app.plus
        assert plus is not None

        # If implicit state has changed, try to respond.
        if self._implicit_state_changed:
            if self._implicit_signed_in_adapter is None:
                # If implicit back-end has signed out, we follow suit
                # immediately; no need to wait for network connectivity.
                accountlog.debug(
                    'Signing out as result of implicit state change...',
                )
                plus.accounts.set_primary_credentials(None)
                self._implicit_state_changed = False

                # Once we've made a move here we don't want to
                # do any more automatic stuff.
                self._can_do_auto_sign_in = False

            else:
                # Ok; we've got a new implicit state. If we've got
                # connectivity, let's attempt to sign in with it.
                # Consider this an 'explicit' sign in because the
                # implicit-login state change presumably was triggered
                # by some user action (signing in, signing out, or
                # switching accounts via the back-end). NOTE: should
                # test case where we don't have connectivity here.
                if plus.cloud.is_connected():
                    accountlog.debug(
                        'Signing in as result of implicit state change...',
                    )
                    self._implicit_signed_in_adapter.sign_in(
                        self._on_explicit_sign_in_completed,
                        description='implicit state change',
                    )
                    self._implicit_state_changed = False

                    # Once we've made a move here we don't want to do
                    # any more automatic stuff.
                    self._can_do_auto_sign_in = False

        if not self._can_do_auto_sign_in:
            return

        # If we're not currently signed in, we have connectivity, and we
        # have an available implicit login, auto-sign-in with it once.
        # The implicit-state-change logic above should keep things
        # mostly in-sync, but that might not always be the case due to
        # connectivity or other issues. We prefer to keep people signed
        # in as a rule, even if there are corner cases where this might
        # not be what they want (A user signing out and then restarting
        # may be auto-signed back in).
        connected = plus.cloud.is_connected()
        signed_in_v1 = plus.get_v1_account_state() == 'signed_in'
        signed_in_v2 = plus.accounts.have_primary_credentials()
        if (
            connected
            and not signed_in_v1
            and not signed_in_v2
            and self._implicit_signed_in_adapter is not None
        ):
            accountlog.debug(
                'Signing in due to on-launch-auto-sign-in...',
            )
            self._can_do_auto_sign_in = False  # Only ATTEMPT once
            self._implicit_signed_in_adapter.sign_in(
                self._on_implicit_sign_in_completed, description='auto-sign-in'
            )

    def _on_explicit_sign_in_completed(
        self,
        adapter: LoginAdapter,
        result: LoginAdapter.SignInResult | Exception,
    ) -> None:
        """A sign-in has completed that the user asked for explicitly."""
        from babase import builtinassets

        del adapter  # Unused.

        plus = _babase.app.plus
        assert plus is not None

        # Make some noise on errors since the user knows a sign-in
        # attempt is happening in this case (the 'explicit' part).
        if isinstance(result, Exception):
            # We expect the occasional communication errors; Log a full
            # exception for anything else though.
            if not isinstance(result, CommunicationError):
                logging.warning(
                    'Error on explicit accountv2 sign in attempt.',
                    exc_info=result,
                )

            # For now just show 'error'. Should do better than this.
            _babase.screenmessage(
                builtinassets.strings.account.sign_in_error,
                color=(1, 0, 0),
            )
            _babase.getsimplesound('error').play()

            # Also I suppose we should sign them out in this case since
            # it could be misleading to be still signed in with the old
            # account.
            plus.accounts.set_primary_credentials(None)
            return

        plus.accounts.set_primary_credentials(result.credentials)

    def _on_implicit_sign_in_completed(
        self,
        adapter: LoginAdapter,
        result: LoginAdapter.SignInResult | Exception,
    ) -> None:
        """A sign-in has completed that the user didn't ask for explicitly."""
        plus = _babase.app.plus
        assert plus is not None

        del adapter  # Unused.

        # Log errors but don't inform the user; they're not aware of this
        # attempt and ignorance is bliss.
        if isinstance(result, Exception):
            # We expect the occasional communication errors;
            # Log a full exception for anything else though.
            if not isinstance(result, CommunicationError):
                logging.warning(
                    'Error on implicit accountv2 sign in attempt.',
                    exc_info=result,
                )
            return

        # If we're still connected and still not signed in,
        # plug in the credentials we got. We want to be extra cautious
        # in case the user has since explicitly signed in since we
        # kicked off.
        connected = plus.cloud.is_connected()
        signed_in_v1 = plus.get_v1_account_state() == 'signed_in'
        signed_in_v2 = plus.accounts.have_primary_credentials()
        if connected and not signed_in_v1 and not signed_in_v2:
            plus.accounts.set_primary_credentials(result.credentials)

    def _on_set_active_workspace_completed(self) -> None:
        if not self._initial_sign_in_completed:
            lifecyclelog.debug('initial-sign-in path: workspace-loaded')
            self._initial_sign_in_completed = True
            _babase.app.on_initial_sign_in_complete()


class AccountV2Handle:
    """Handle for interacting with a V2 account.

    This class supports the ``with`` statement, which is how it is
    used with some operations such as cloud messaging.

    Do not instantiate this class directly. Always access account
    handles through the accounts subsystem; for example via
    :attr:`babase.AccountV2Subsystem.primary`.
    """

    def __init__(self) -> None:
        # We use type() instead of isinstance() here intentionally;
        # subclasses should be allowed to instantiate.
        if (  # pylint: disable=unidiomatic-typecheck
            type(self) is AccountV2Handle
        ):
            raise TypeError(
                'AccountV2Handle cannot be instantiated directly.'
                ' Access account handles through the accounts subsystem'
                ' (e.g. babase.app.plus.accounts.primary).'
            )

    #: The id of this account.
    accountid: str

    #: The last known tag for this account.
    tag: str

    #: The name of the workspace being synced to this client.
    workspacename: str | None

    #: The id of the workspace being synced to this client, if any.
    workspaceid: str | None

    #: Info about last known logins associated with this account.
    logins: dict[LoginType, LoginInfo]

    def __enter__(self) -> None:
        """Support for "with" statement.

        This allows cloud messages to be sent on our behalf.
        """

    def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> Any:
        """Support for "with" statement.

        This allows cloud messages to be sent on our behalf.
        """

    def request_transient_api_key(
        self, on_response: Callable[[str | Exception], None]
    ) -> None:
        """Request a transient API key for this account.

        Calls on_response with the key string on success, or an Exception
        on failure. Always called in the logic thread.

        Note that keys may be rotated in some cases, so it is best to
        re-request a key at least once per hour rather than caching it
        indefinitely.
        """
        import bacommon.cloud

        assert _babase.in_logic_thread()

        plus = _babase.app.plus
        assert plus is not None

        def _on_raw_response(
            response: bacommon.cloud.TransientAPIKeyResponse | Exception,
        ) -> None:
            if isinstance(response, Exception):
                on_response(response)
                return
            if response.key is not None:
                on_response(response.key)
                return
            on_response(
                RuntimeError(
                    f'Transient API key request failed: {response.error}'
                )
            )

        with self:
            plus.cloud.send_message_cb(
                bacommon.cloud.TransientAPIKeyRequest(),
                on_response=_on_raw_response,
            )


@dataclass
class _AuthRequest:
    expire_time: float
    error: str | None
    token: str | None
    reason: int | None
