from __future__ import annotations

import dataclasses
from types import MappingProxyType
from typing import ClassVar, Pattern, Dict, Set, Match, Optional, Mapping, List
from urllib.parse import urlencode, urlparse, ParseResult, quote, parse_qs

from typic.util import cached_property, slotted
from .secret import SecretStr
from .url import (
    DEFAULT_PORTS,
    INTERNAL_HOSTS,
    INTERNAL_IP_PATTERN,
    NetworkAddress,
    NET_ADDR_PATTERN,
    NetworkAddressValueError,
    PRIVATE_HOSTS,
)

__all__ = ("DSN", "DSNInfo", "DSNValueError")


class DSNValueError(NetworkAddressValueError):
    """A generic error indicating the given value is not a valid DSN.

    Inherits from :py:class:`~typic.types.NetworkAddressValueError`.
    """

    pass


@slotted(dict=True)
@dataclasses.dataclass(frozen=True)
class DSNInfo:
    """Detailed information about a D(ata)S(ource)N(ame).

    Can be called directly, generated by casting a :py:class:`str` as :py:class:`DSN`,
    or created with :py:meth:`DSNInfo.from_str`

    Notes
    -----
    DSNs are *technically* a type of network address, but are more strict.
    There's also a semi-standard API for interacting with them, thanks to SQLAlchemy, etc.
    So we have our own `-Info` object which conforms more closely with the expected API.

    See Also
    --------
    :py:class:`typic.types.NetAddrInfo`
    """

    driver: str
    """The database driver, e.g., `mysql`."""
    username: str
    """The username used for authentication."""
    password: SecretStr
    """The password used for authentication."""
    host: str
    """The host address where the server is located."""
    port: int
    """The exposed port to connect to."""
    name: str
    """The database name."""
    qs: str
    """The query-string to pass on to the database."""
    is_ip: bool = False

    PATTERN: ClassVar[Pattern] = NET_ADDR_PATTERN
    DEFAULT_PORTS: ClassVar[Dict] = DEFAULT_PORTS
    PRIVATE_HOSTS: ClassVar[Set[str]] = PRIVATE_HOSTS
    INTERNAL_HOSTS: ClassVar[Set[str]] = INTERNAL_HOSTS

    @classmethod
    def from_str(cls, value) -> "DSNInfo":
        """Parse & validate a string and generate an instance of :py:class:`DSNInfo`."""
        match: Optional[Match] = cls.PATTERN.match(value)
        if not match or not value:
            raise DSNValueError(f"{value!r} is not a valid DSN.")
        scheme, host = match["scheme"] or "", match["host"] or ""
        if not scheme or not host:
            raise DSNValueError(f"{value!r} is not a valid DSN, missing driver|host.")

        port = int(match["port"] or 0)
        if port == 0 and cls.DEFAULT_PORTS[scheme]:
            port = cls.DEFAULT_PORTS[scheme].copy().pop()
        if port == 0:
            raise DSNValueError(
                f"{value!r} is not a valid DSN, couldn't determine port."
            )
        parsed: ParseResult = urlparse(match["relative"] or "")
        return cls(
            driver=scheme,
            host=host,
            username=match["username"] or "",
            password=SecretStr(match["password"] or ""),
            qs=parsed.query,
            port=port,
            name=parsed.path,
            is_ip=bool(match["ipv4"] or match["ipv6"]),
        )  # type: ignore

    @cached_property
    def base(self) -> str:
        """The 'base' of this DSN.

        Includes driver, user/pass, host, & port"""
        return f"{self.driver}://{self.username}:{self.password.secret}@{self.host}:{self.port}"

    @cached_property
    def relative(self):
        """The 'relative' portion of this DSN.

        Includes the database/schema name and query-string.
        """
        return f"{self.name}{urlencode(self.query)}"

    @cached_property
    def address(self) -> str:
        """The fully-qualified address.

        If this instance was generated from a string, it will match the string exactly,
        EXCEPT if a port wasn't provided in the original string. In those cases, we try
        to determine the appropriate port based upon the driver name provided.
        """
        return f"{self.base}{self.relative}"

    @cached_property
    def address_encoded(self) -> str:
        """The fully-qualified address, encoded."""
        return quote(self.address)  # type: ignore

    @cached_property
    def query(self) -> Mapping[str, List[str]]:
        """The query-string, parsed into a mapping of key -> [values, ...]."""
        return MappingProxyType(parse_qs(self.qs) if self.qs else {})

    @cached_property
    def is_default_port(self) -> bool:
        """Whether or not the port is the default for the SQL dialect."""
        defaults = DEFAULT_PORTS[self.driver.split("+")[0]] | {0}
        return self.port in defaults

    @cached_property
    def is_private(self) -> bool:
        """Whether the host is a private host, i.e., 'localhost'."""
        return self.host in PRIVATE_HOSTS

    @cached_property
    def is_internal(self) -> bool:
        """Whether the host provided is an internal IP/DNS.

        Internal IP/DNS addresses aren't necessarily private, hence the distinction.
        """
        return bool(
            self.host in INTERNAL_HOSTS
            or (self.is_ip and INTERNAL_IP_PATTERN.match(self.host))
        )


# Deepcopy is broken for frozen dataclasses with slots.
# https://github.com/python/cpython/pull/17254
# DSNInfo.__slots__ = tuple(_.name for _ in dataclasses.fields(DSNInfo))


class DSN(NetworkAddress):
    """A Data Source Name string.

    Detailed information about the DSN string can be looked up via :py:attr:`DSN.info`.

    See Also
    --------
    :py:class:`~typic.types.NetworkAddress`

    Examples
    --------
    >>> import typic
    >>> dsn = typic.DSN("postgresql://user:secret@localhost:5432/mydb")
    >>> dsn
    'postgresql://user:secret@localhost:5432/mydb'
    >>> dsn.info.host
    'localhost'
    >>> dsn.info.is_private
    True
    >>> dsn.info.is_default_port
    True
    >>> dsn.info.username
    'user'
    >>> dsn.info.password  # This has been converted to a secret :)
    ******
    >>> dsn.info.name
    '/mydb'
    >>> dsn.info.driver
    'postgresql'
    >>> import json
    >>> json.dumps([dsn])
    '["postgresql://user:secret@localhost:5432/mydb"]'

    Notes
    -----
    This object inherits from :py:class:`str` and, so is natively JSON-serializable.
    """

    @cached_property
    def info(self) -> DSNInfo:  # type: ignore
        """Get detailed information about your DSN string.

        See Also
        --------
        :py:class:`DSNInfo`
        """
        return DSNInfo.from_str(self)
