"""Some general objects for use in JupyterHub"""

# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import socket
import warnings
from urllib.parse import urlparse, urlunparse

from traitlets import HasTraits, Instance, Integer, Unicode, default, observe, validate

from . import orm
from .traitlets import URLPrefix
from .utils import (
    can_connect,
    fmt_ip_url,
    make_ssl_context,
    random_port,
    url_path_join,
    wait_for_http_server,
    wait_for_server,
)


class Server(HasTraits):
    """An object representing an HTTP endpoint.

    *Some* of these reside in the database (user servers),
    but others (Hub, proxy) are in-memory only.
    """

    orm_server = Instance(orm.Server, allow_none=True)

    ip = Unicode()
    connect_ip = Unicode()
    connect_port = Integer()
    proto = Unicode('http')
    port = Integer()
    base_url = URLPrefix('/')
    cookie_name = Unicode('')
    connect_url = Unicode('')
    bind_url = Unicode('')
    certfile = Unicode()
    keyfile = Unicode()
    cafile = Unicode()

    @default('bind_url')
    def bind_url_default(self):
        """representation of URL used for binding

        Never used in APIs, only logging,
        since it can be non-connectable value, such as '', meaning all interfaces.
        """
        if self.ip in {'', '0.0.0.0', '::'}:
            return self.url.replace(self._connect_ip, fmt_ip_url(self.ip) or '*', 1)
        return self.url

    @observe('bind_url')
    def _bind_url_changed(self, change):
        urlinfo = urlparse(change.new)
        self.proto = urlinfo.scheme
        self.ip = urlinfo.hostname or ''
        port = urlinfo.port
        if port is None:
            if self.proto == 'https':
                port = 443
            else:
                port = 80
        self.port = port

    @validate('connect_url')
    def _connect_url_add_prefix(self, proposal):
        """Ensure connect_url includes base_url"""
        if not proposal.value:
            # Don't add the prefix if the setting is being cleared
            return proposal.value
        urlinfo = urlparse(proposal.value)
        if not urlinfo.path.startswith(self.base_url):
            urlinfo = urlinfo._replace(path=self.base_url)
            return urlunparse(urlinfo)
        return proposal.value

    @property
    def _connect_ip(self):
        """The address to use when connecting to this server

        When `ip` is set to a real ip address, the same value is used.
        When `ip` refers to 'all interfaces' (e.g. '0.0.0.0' or '::'),
        clients connect via hostname by default.
        Setting `connect_ip` explicitly overrides any default behavior.
        """
        if self.connect_ip:
            return self.connect_ip
        elif self.ip in {'', '0.0.0.0', '::'}:
            # if listening on all interfaces, default to hostname for connect
            return socket.gethostname()
        else:
            return self.ip

    @property
    def _connect_port(self):
        """
        The port to use when connecting to this server.

        Defaults to self.port, but can be overridden by setting self.connect_port
        """
        if self.connect_port:
            return self.connect_port
        return self.port

    @classmethod
    def from_orm(cls, orm_server):
        """Create a server from an orm.Server"""
        return cls(orm_server=orm_server)

    @classmethod
    def from_url(cls, url):
        """Create a Server from a given URL"""
        return cls(bind_url=url, base_url=urlparse(url).path)

    @default('port')
    def _default_port(self):
        return random_port()

    @observe('orm_server')
    def _orm_server_changed(self, change):
        """When we get an orm_server, get attributes from there."""
        obj = change.new
        self.proto = obj.proto
        self.ip = obj.ip
        self.port = obj.port
        self.base_url = obj.base_url
        self.cookie_name = obj.cookie_name

    # setter to pass through to the database
    @observe('ip', 'proto', 'port', 'base_url', 'cookie_name')
    def _change(self, change):
        if self.orm_server and getattr(self.orm_server, change.name) != change.new:
            # setattr on an sqlalchemy object sets the dirty flag,
            # even if the value doesn't change.
            # Avoid calling setattr when there's been no change,
            # to avoid setting the dirty flag and triggering rollback.
            setattr(self.orm_server, change.name, change.new)

    @property
    def host(self):
        if self.connect_url:
            parsed = urlparse(self.connect_url)
            proto = parsed.scheme
            host = parsed.netloc
            return f"{proto}://{host}"

        if ':' in self._connect_ip:
            fmt = "{proto}://[{ip}]:{port}"
        else:
            fmt = "{proto}://{ip}:{port}"
        return fmt.format(
            proto=self.proto, ip=self._connect_ip, port=self._connect_port
        )

    @property
    def url(self):
        if self.connect_url:
            return self.connect_url
        return f"{self.host}{self.base_url}"

    def __repr__(self):
        return f"{self.__class__.__name__}(url={self.url}, bind_url={self.bind_url})"

    def wait_up(self, timeout=10, http=False, ssl_context=None, extra_path=""):
        """Wait for this server to come up"""
        if http:
            ssl_context = ssl_context or make_ssl_context(
                self.keyfile, self.certfile, cafile=self.cafile
            )

            return wait_for_http_server(
                url_path_join(self.url, extra_path),
                timeout=timeout,
                ssl_context=ssl_context,
            )
        else:
            return wait_for_server(
                self._connect_ip, self._connect_port, timeout=timeout
            )

    def is_up(self):
        """Is the server accepting connections?"""
        return can_connect(self._connect_ip, self._connect_port)


class Hub(Server):
    """Bring it all together at the hub.

    The Hub is a server, plus its API path suffix

    the api_url is the full URL plus the api_path suffix on the end
    of the server base_url.
    """

    cookie_name = 'jupyterhub-hub-login'

    @property
    def server(self):
        warnings.warn(
            "Hub.server is deprecated in JupyterHub 0.8. Access attributes on the Hub directly.",
            DeprecationWarning,
            stacklevel=2,
        )
        return self

    public_host = Unicode()
    routespec = Unicode()

    @property
    def api_url(self):
        """return the full API url (with proto://host...)"""
        return url_path_join(self.url, 'api')

    def __repr__(self):
        return f"<{self.__class__.__name__} {fmt_ip_url(self.ip)}:{self.port}>"
