"""A service is a process that talks to JupyterHub.

Types of services:
    Managed:
      - managed by JupyterHub (always subprocess, no custom Spawners)
      - always a long-running process
      - managed services are restarted automatically if they exit unexpectedly

    Unmanaged:
      - managed by external service (docker, systemd, etc.)
      - do not need to be long-running processes, or processes at all

URL: needs a route added to the proxy.
  - Public route will always be /services/service-name
  - url specified in config
  - if port is 0, Hub will select a port

API access:
  - admin: tokens will have admin-access to the API
  - not admin: tokens will only have non-admin access
    (not much they can do other than defer to Hub for auth)

An externally managed service running on a URL::

    {
        'name': 'my-service',
        'url': 'https://host:8888',
        'admin': True,
        'api_token': 'super-secret',
    }

A hub-managed service with no URL::

    {
        'name': 'cull-idle',
        'command': ['python', '/path/to/cull-idle']
        'admin': True,
    }

"""

import asyncio
import copy
import os
import shlex
import shutil
from subprocess import Popen
from urllib.parse import urlparse, urlunparse

from traitlets import (
    Any,
    Bool,
    Dict,
    HasTraits,
    Instance,
    List,
    Set,
    Unicode,
    default,
    observe,
    validate,
)
from traitlets.config import LoggingConfigurable

from .. import orm
from ..objects import Server
from ..spawner import LocalProcessSpawner, set_user_setuid
from ..traitlets import Command
from ..utils import url_path_join


class _MockUser(HasTraits):
    name = Unicode()
    server = Instance(orm.Server, allow_none=True)
    state = Dict()
    service = Instance(__name__ + '.Service')
    host = Unicode()

    @property
    def url(self):
        if not self.server:
            return ''
        if self.host:
            return self.host + self.server.base_url
        else:
            return self.server.base_url

    @property
    def base_url(self):
        if not self.server:
            return ''
        return self.server.base_url


# We probably shouldn't use a Spawner here,
# but there are too many concepts to share.


class _ServiceSpawner(LocalProcessSpawner):
    """Subclass of LocalProcessSpawner

    Removes notebook-specific-ness from LocalProcessSpawner.
    """

    cwd = Unicode()
    cmd = Command(minlen=0)
    _service_name = Unicode()

    @default("oauth_access_scopes")
    def _default_oauth_access_scopes(self):
        return [
            "access:services",
            f"access:services!service={self._service_name}",
        ]

    def make_preexec_fn(self, name):
        if not name:
            # no setuid if no name
            return
        return set_user_setuid(name, chdir=False)

    def user_env(self, env):
        if not self.user.name:
            return env
        else:
            return super().user_env(env)

    def start(self):
        """Start the process"""
        env = self.get_env()
        # no activity url for services
        env.pop('JUPYTERHUB_ACTIVITY_URL', None)
        if os.name == 'nt':
            env['SYSTEMROOT'] = os.environ['SYSTEMROOT']
        cmd = self.cmd

        self.log.info("Spawning %s", ' '.join(shlex.quote(s) for s in cmd))
        try:
            self.proc = Popen(
                self.cmd,
                env=env,
                preexec_fn=self.make_preexec_fn(self.user.name),
                start_new_session=True,  # don't forward signals
                cwd=self.cwd or None,
            )
        except PermissionError:
            # use which to get abspath
            script = shutil.which(cmd[0]) or cmd[0]
            self.log.error(
                "Permission denied trying to run %r. Does %s have access to this file?",
                script,
                self.user.name,
            )
            raise

        self.pid = self.proc.pid


class Service(LoggingConfigurable):
    """An object wrapping a service specification for Hub API consumers.

    A service has inputs:

    - name: str
        the name of the service
    - admin: bool(False)
        whether the service should have administrative privileges
    - url: str (None)
        The URL where the service is/should be.
        If specified, the service will be added to the proxy at /services/:name
    - oauth_redirect_url: str ('/services/:name/oauth_redirect')
        The URI for the oauth redirect.

        Not usually needed, but must be set for external services that are not accessed through the proxy,
        or any service which have a redirect URI different from the default of `/services/:name/oauth_redirect`.

    - oauth_no_confirm: bool(False)
        Whether this service should be allowed to complete oauth
        with logged-in users without prompting for confirmation.

    If a service is to be managed by the Hub, it has a few extra options:

    - command: (str/Popen list)
        Command for JupyterHub to spawn the service.
        Only use this if the service should be a subprocess.
        If command is not specified, it is assumed to be managed
        by an external process.
    - environment: dict
        Additional environment variables for the service.
    - user: str
        The name of a system user to become.
        If unspecified, run as the same user as the Hub.

    """

    # traits tagged with `input=True` are accepted as input from configuration / API
    # input traits are also persisted to the db UNLESS they are also tagged with `in_db=False`

    name = Unicode(
        help="""The name of the service.

        If the service has an http endpoint, it
        """
    ).tag(input=True)
    admin = Bool(False, help="Does the service need admin-access to the Hub API?").tag(
        input=True
    )
    url = Unicode(
        help="""URL of the service.

        Only specify if the service runs an HTTP(s) endpoint that.
        If managed, will be passed as JUPYTERHUB_SERVICE_URL env.
        """
    ).tag(input=True)

    oauth_roles = List(
        help="""OAuth allowed roles.

        DEPRECATED in 3.0: use oauth_client_allowed_scopes
      """
    ).tag(input=True, in_db=False)

    oauth_client_allowed_scopes = List(
        help="""OAuth allowed scopes.

        This sets the maximum and default scopes
        assigned to oauth tokens issued for this service
        (i.e. tokens stored in browsers after authenticating with the server),
        defining what actions the service can take on behalf of logged-in users.

        Default is an empty list, meaning minimal permissions to identify users,
        no actions can be taken on their behalf.
      """
    ).tag(input=True)

    api_token = Unicode(
        help="""The API token to use for the service.

        If unspecified, an API token will be generated for managed services.
        """
    ).tag(input=True, in_db=False)

    info = Dict(
        help="""Provide a place to include miscellaneous information about the service,
        provided through the configuration
        """
    ).tag(input=True)

    display = Bool(
        True, help="""Whether to list the service on the JupyterHub UI"""
    ).tag(input=True)

    oauth_no_confirm = Bool(
        False,
        help="""Skip OAuth confirmation when users access this service.

        By default, when users authenticate with a service using JupyterHub,
        they are prompted to confirm that they want to grant that service
        access to their credentials.
        Setting oauth_no_confirm=True skips the confirmation web page for this service.
        Skipping the confirmation page is useful for admin-managed services that are considered part of the Hub
        and shouldn't need extra prompts for login.

        .. versionadded: 1.1
        """,
    ).tag(input=True)

    # Managed service API:
    spawner = Any()

    @property
    def managed(self):
        """Am I managed by the Hub?"""
        return bool(self.command)

    @property
    def kind(self):
        """The name of the kind of service as a string

        - 'managed' for managed services
        - 'external' for external services
        """
        return 'managed' if self.managed else 'external'

    command = Command(minlen=0, help="Command to spawn this service, if managed.").tag(
        input=True
    )
    cwd = Unicode(help="""The working directory in which to run the service.""").tag(
        input=True
    )
    environment = Dict(
        help="""Environment variables to pass to the service.
        Only used if the Hub is spawning the service.
        """
    ).tag(input=True)
    user = Unicode(
        "",
        help="""The user to become when launching the service.

        If unspecified, run the service as the same user as the Hub.
        """,
    ).tag(input=True)

    domain = Unicode()
    host = Unicode()
    hub = Any()
    app = Any()
    proc = Any()

    # handles on globals:
    proxy = Any()
    base_url = Unicode()
    db = Any()
    orm = Any()
    roles = Any()
    cookie_options = Dict()

    oauth_provider = Any()
    _oauth_specified = Set(help="List of oauth config fields specified via config.")

    oauth_client_id = Unicode(
        help="""OAuth client ID for this service.

        You shouldn't generally need to change this.
        Default: `service-<name>`
        """
    ).tag(input=True, in_db=False)

    @default('oauth_client_id')
    def _default_client_id(self):
        return f'service-{self.name}'

    @validate("oauth_client_id")
    def _validate_client_id(self, proposal):
        if not proposal.value.startswith("service-"):
            raise ValueError(
                f"service {self.name} has oauth_client_id='{proposal.value}'."
                " Service oauth client ids must start with 'service-'"
            )
        return proposal.value

    oauth_redirect_uri = Unicode(
        help="""OAuth redirect URI for this service.

        Must be set for external services that are not accessed through the proxy,
        or any service which have a redirect URI different from the default.

        Default: `/services/:name/oauth_callback`
        """
    ).tag(input=True, in_db=False)

    @default('oauth_redirect_uri')
    def _default_redirect_uri(self):
        if self.server is None:
            return ''
        return self.host + url_path_join(self.prefix, 'oauth_callback')

    @observe("oauth_client_id", "oauth_redirect_uri", "oauth_no_confirm")
    def _oauth_config_set(self, change):
        # record that some oauth config is specified
        if change.new in {"", None}:
            # this shouldn't happen, but empty values
            # may sometimes be set to 'un-set' config
            self._oauth_specified.pop(change.name, None)
        else:
            self._oauth_specified.add(change.name)

    @property
    def oauth_available(self):
        """Is OAuth available for this client?

        Returns True if a server is defined or any oauth config is provided explicitly
        """
        return bool(self.server is not None or self._oauth_specified)

    @property
    def oauth_client(self):
        return self.orm.oauth_client

    @property
    def server(self):
        if self.orm.server:
            return Server.from_orm(self.orm.server)
        else:
            return None

    @property
    def prefix(self):
        return url_path_join(self.base_url, 'services', self.name + '/')

    @property
    def href(self):
        """Convenient 'href' to use for links to this service"""
        if self.domain:
            return f"//{self.domain}{self.prefix}"
        else:
            return self.prefix

    @property
    def proxy_spec(self):
        if not self.server:
            return ''
        if self.domain:
            return self.domain + self.server.base_url
        else:
            return self.server.base_url

    @property
    def from_config(self):
        """Is the service defined from config file?"""
        return self.orm.from_config

    def __repr__(self):
        return "<{cls}(name={name}{managed})>".format(
            cls=self.__class__.__name__,
            name=self.name,
            managed=' managed' if self.managed else '',
        )

    async def start(self):
        """Start a managed service"""
        if not self.managed:
            raise RuntimeError(f"Cannot start unmanaged service {self}")
        self.log.info("Starting service %r: %r", self.name, self.command)

        hub = self.hub

        env = {}
        env.update(self.environment)
        spawn_kwargs = {}

        if self.app.public_url:
            # strip path part, just the proto://host[:port]
            hub_public_origin = urlunparse(
                urlparse(self.app.public_url)._replace(path="")
            )
            spawn_kwargs["public_hub_url"] = url_path_join(
                hub_public_origin, hub.base_url
            )
        else:
            hub_public_origin = None

        env['JUPYTERHUB_SERVICE_NAME'] = self.name
        if self.url:
            env['JUPYTERHUB_SERVICE_URL'] = self.url
            env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url

            # set public url
            public_origin = self.host or hub_public_origin
            if public_origin:
                spawn_kwargs["public_url"] = url_path_join(
                    public_origin, self.server.base_url
                )

        if self.hub.ip in ('', '0.0.0.0', '::'):
            # if the Hub is listening on all interfaces,
            # tell services to connect via localhost
            # since they are always local subprocesses
            hub = copy.deepcopy(self.hub)
            hub.connect_url = ''
            if self.hub.ip and ":" in self.hub.ip:
                hub.connect_ip = "::1"
            else:
                hub.connect_ip = "127.0.0.1"

        self.spawner = _ServiceSpawner(
            cmd=self.command,
            environment=env,
            api_token=self.api_token,
            oauth_client_id=self.oauth_client_id,
            _service_name=self.name,
            cookie_options=self.cookie_options,
            cwd=self.cwd,
            hub=self.hub,
            user=_MockUser(
                name=self.user, service=self, server=self.orm.server, host=self.host
            ),
            internal_ssl=self.app.internal_ssl,
            internal_certs_location=self.app.internal_certs_location,
            internal_trust_bundles=self.app.internal_trust_bundles,
            **spawn_kwargs,
        )
        if self.spawner.internal_ssl:
            self.spawner.cert_paths = await self.spawner.create_certs()
        self.spawner.start()
        self.proc = self.spawner.proc
        self.spawner.add_poll_callback(self._proc_stopped)
        self.spawner.start_polling()

    def _proc_stopped(self):
        """Called when the service process unexpectedly exits"""
        self.log.error(
            "Service %s exited with status %i", self.name, self.proc.returncode
        )
        # schedule start
        asyncio.ensure_future(self.start())

    async def stop(self):
        """Stop a managed service"""
        self.log.debug("Stopping service %s", self.name)
        if not self.managed:
            raise RuntimeError(f"Cannot stop unmanaged service {self}")
        if self.spawner:
            if self.orm.server:
                self.db.delete(self.orm.server)
                self.db.commit()
            self.spawner.stop_polling()
            return await self.spawner.stop()
