# The core application of the Mia project.
#   by imacat <imacat@mail.imacat.idv.tw>, 2020/7/1

#  Copyright (c) 2020 imacat.
#
#  Licensed under the Apache License, Version 2.0 (the "License");
#  you may not use this file except in compliance with the License.
#  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
#  limitations under the License.

"""The utilities of the Mia core application.

"""
import datetime
import urllib.parse
from secrets import randbelow
from typing import Dict, List, Any, Type, Optional

from django.conf import settings
from django.db.models import Model
from django.http import HttpRequest
from django.urls import reverse
from django.utils.translation import pgettext, get_language


def new_pk(cls: Type[Model]) -> int:
    """Finds a random ID that does not conflict with the existing data records.

    Args:
        cls: The Django model class.

    Returns:
         The new random ID.
    """
    while True:
        pk = 100000000 + randbelow(900000000)
        try:
            cls.objects.get(pk=pk)
        except cls.DoesNotExist:
            return pk


def strip_post(post: Dict[str, str]) -> None:
    """Strips the values of the POSTed data.  Empty strings are removed.

    Args:
        post (dict[str]): The POSTed data.
    """
    for key in list(post.keys()):
        post[key] = post[key].strip()
        if post[key] == "":
            del post[key]


STORAGE_KEY: str = "stored_post"


def store_post(request: HttpRequest, post: Dict[str, str]):
    """Stores the POST data into the session.

    Args:
        request: The request.
        post: The POST data.
    """
    request.session[STORAGE_KEY] = post


def retrieve_store(request: HttpRequest) -> Optional[Dict[str, str]]:
    """Retrieves the POST data from the storage.

    Args:
        request: The request.

    Returns:
        The POST data, or None if the previously-stored data does not exist.
    """
    if STORAGE_KEY not in request.session:
        return None
    post = request.session[STORAGE_KEY]
    del request.session[STORAGE_KEY]
    return post


def parse_date(s: str):
    """Parses a string for a date.  The date can be either YYYY-MM-DD,
    Y/M/D, or M/D/Y.

    Args:
        s: The string.

    Returns:
        The date.

    Raises:
        ValueError: When the string is not in a valid format.
    """
    for f in ["%Y-%m-%d", "%m/%d/%Y", "%Y/%m/%d"]:
        try:
            return datetime.datetime.strptime(s, f)
        except ValueError:
            pass
    raise ValueError(F"not a recognized date {s}")


class Language:
    """A language.

    Args:
        language: The Django language code.

    Attributes:
        id (str): The language ID
        db (str): The database column suffix of this language.
        locale (str); The locale name of this language.
        is_default (bool): Whether this is the default language.
    """
    def __init__(self, language: str):
        self.id = language
        self.db = "_" + language.lower().replace("-", "_")
        if language == "zh-hant":
            self.locale = "zh-TW"
        elif language == "zh-hans":
            self.locale = "zh-CN"
        else:
            self.locale = language
        self.is_default = (language == settings.LANGUAGE_CODE)

    @staticmethod
    def default():
        return Language(settings.LANGUAGE_CODE)

    @staticmethod
    def current():
        return Language(get_language())


class UrlBuilder:
    """The URL builder.

    Attributes:
        path (str): the base path
        params (list[Param]): The query parameters
    """
    def __init__(self, start_url: str):
        """Constructs a new URL builder.

        Args:
            start_url (str): The URL to start with
        """
        pos = start_url.find("?")
        if pos == -1:
            self.path = start_url
            self.params = []
            return
        self.path = start_url[:pos]
        self.params = []
        for piece in start_url[pos + 1:].split("&"):
            pos = piece.find("=")
            name = urllib.parse.unquote_plus(piece[:pos])
            value = urllib.parse.unquote_plus(piece[pos + 1:])
            self.params.append(self.Param(name, value))

    def add(self, name, value):
        """Adds a query parameter.

        Args:
            name (str): The parameter name
            value (str): The parameter value

        Returns:
            UrlBuilder: The URL builder itself, with the parameter
                modified.
        """
        if value is not None:
            self.params.append(self.Param(name, value))
        return self

    def remove(self, name):
        """Removes a query parameter.

        Args:
            name (str): The parameter name

        Returns:
            UrlBuilder: The URL builder itself, with the parameter
                modified.
        """
        self.params = [x for x in self.params if x.name != name]
        return self

    def query(self, **kwargs):
        """A keyword-styled query parameter setter.  The existing values are
        always replaced.  Multiple-values are added when the value is a list or
        tuple.  The existing values are dropped when the value is None.
        """
        for key in kwargs:
            self.remove(key)
            if isinstance(kwargs[key], list) or isinstance(kwargs[key], tuple):
                for value in kwargs[key]:
                    self.add(key, value)
            elif kwargs[key] is None:
                pass
            else:
                self.add(key, kwargs[key])
        return self

    def clone(self):
        """Returns a copy of this URL builder.

        Returns:
            UrlBuilder: A copy of this URL builder.
        """
        another = UrlBuilder(self.path)
        another.params = [
            self.Param(x.name, x.value) for x in self.params]
        return another

    def __str__(self) -> str:
        if len(self.params) == 0:
            return self.path
        return self.path + "?" + "&".join([
            str(x) for x in self.params])

    class Param:
        """A query parameter.

        Attributes:
            name: The parameter name
            value: The parameter value
        """
        def __init__(self, name: str, value: str):
            """Constructs a new query parameter

            Args:
                name (str): The parameter name
                value (str): The parameter value
            """
            self.name = name
            self.value = value

        def __str__(self) -> str:
            """Returns the string representation of this query
            parameter.

            Returns:
                str: The string representation of this query
                    parameter
            """
            return "%s=%s" % (
                urllib.parse.quote(self.name),
                urllib.parse.quote(self.value))


class Pagination:
    """The pagination.

    Args:
        request: The request.
        items: All the items.
        is_reversed: Whether we should display the last page first.

    Raises:
        PaginationException: With invalid pagination parameters

    Attributes:
        current_url (UrlBuilder): The current request URL.
        is_reversed (bool): Whether we should display the last page first.
        page_size (int): The page size.
        total_pages (int): The total number of pages available.
        is_paged (bool): Whether there are more than one page.
        page_no (int): The current page number.
        items (list[Model]): The items in the current page.
    """
    DEFAULT_PAGE_SIZE = 10

    def __init__(self, request: HttpRequest, items: List[Any],
                 is_reversed: bool = False):
        self.current_url = UrlBuilder(request.get_full_path())
        self.is_reversed = is_reversed
        self.page_size = self.DEFAULT_PAGE_SIZE
        self.total_pages = None
        self.is_paged = None
        self.page_no = 1
        self.items = []

        # The page size
        try:
            self.page_size = int(request.GET["page-size"])
            if self.page_size == self.DEFAULT_PAGE_SIZE:
                raise PaginationException(self.current_url.remove("page-size"))
            if self.page_size < 1:
                raise PaginationException(self.current_url.remove("page-size"))
        except KeyError:
            self.page_size = self.DEFAULT_PAGE_SIZE
        except ValueError:
            raise PaginationException(self.current_url.remove("page-size"))
        self.total_pages = int(
            (len(items) - 1) / self.page_size) + 1
        default_page_no = 1 if not is_reversed else self.total_pages
        self.is_paged = self.total_pages > 1

        # The page number
        try:
            self.page_no = int(request.GET["page"])
            if not self.is_paged:
                raise PaginationException(self.current_url.remove("page"))
            if self.page_no == default_page_no:
                raise PaginationException(self.current_url.remove("page"))
            if self.page_no < 1:
                raise PaginationException(self.current_url.remove("page"))
            if self.page_no > self.total_pages:
                raise PaginationException(self.current_url.remove("page"))
        except KeyError:
            self.page_no = default_page_no
        except ValueError:
            raise PaginationException(self.current_url.remove("page"))

        if not self.is_paged:
            self.page_no = 1
            self.items = items
            return
        start_no = self.page_size * (self.page_no - 1)
        self.items = items[start_no:start_no + self.page_size]

    def links(self):
        """Returns the navigation links of the pagination bar.

        Returns:
            List[Link]: The navigation links of the pagination bar.
        """
        base_url = self.current_url.clone().remove("page").remove("s")
        links = []
        # The previous page
        link = self.Link()
        link.title = pgettext("Pagination|", "Previous")
        if self.page_no > 1:
            if self.page_no - 1 == 1:
                if not self.is_reversed:
                    link.url = str(base_url)
                else:
                    link.url = str(base_url.clone().add(
                        "page", "1"))
            else:
                link.url = str(base_url.clone().add(
                    "page", str(self.page_no - 1)))
        link.is_small_screen = True
        links.append(link)
        # The first page
        link = self.Link()
        link.title = "1"
        if not self.is_reversed:
            link.url = str(base_url)
        else:
            link.url = str(base_url.clone().add(
                "page", "1"))
        if self.page_no == 1:
            link.is_active = True
        links.append(link)
        # The previous ellipsis
        if self.page_no > 4:
            link = self.Link()
            if self.page_no > 5:
                link.title = pgettext("Pagination|", "...")
            else:
                link.title = "2"
                link.url = str(base_url.clone().add(
                    "page", "2"))
            links.append(link)
        # The nearby pages
        for no in range(self.page_no - 2, self.page_no + 3):
            if no <= 1 or no >= self.total_pages:
                continue
            link = self.Link()
            link.title = str(no)
            link.url = str(base_url.clone().add(
                "page", str(no)))
            if no == self.page_no:
                link.is_active = True
            links.append(link)
        # The next ellipsis
        if self.page_no + 3 < self.total_pages:
            link = self.Link()
            if self.page_no + 4 < self.total_pages:
                link.title = pgettext("Pagination|", "...")
            else:
                link.title = str(self.total_pages - 1)
                link.url = str(base_url.clone().add(
                    "page", str(self.total_pages - 1)))
            links.append(link)
        # The last page
        link = self.Link()
        link.title = str(self.total_pages)
        if self.is_reversed:
            link.url = str(base_url)
        else:
            link.url = str(base_url.clone().add(
                "page", str(self.total_pages)))
        if self.page_no == self.total_pages:
            link.is_active = True
        links.append(link)
        # The next page
        link = self.Link()
        link.title = pgettext("Pagination|", "Next")
        if self.page_no < self.total_pages:
            if self.page_no + 1 == self.total_pages:
                if self.is_reversed:
                    link.url = str(base_url)
                else:
                    link.url = str(base_url.clone().add(
                        "page", str(self.total_pages)))
            else:
                link.url = str(base_url.clone().add(
                    "page", str(self.page_no + 1)))
        link.is_small_screen = True
        links.append(link)
        return links

    class Link:
        """A navigation link in the pagination bar.

        Attributes:
            url (str): The link URL, or for a non-link slot.
            title (str): The title of the link.
            is_active (bool): Whether this link is currently active.
            is_small_screen (bool): Whether this link is for small screens.
        """
        def __int__(self):
            self.url = None
            self.title = None
            self.is_active = False
            self.is_small_screen = False

    def page_size_options(self):
        """Returns the page size options.

        Returns:
            List[PageSizeOption]: The page size options.
        """
        base_url = self.current_url.remove("page").remove("page-size")
        return [self.PageSizeOption(x, self._page_size_url(base_url, x))
                for x in [10, 100, 200]]

    @staticmethod
    def _page_size_url(base_url: UrlBuilder, size: int) -> str:
        """Returns the URL for a new page size.

        Args:
            base_url (UrlBuilder): The base URL builder.
            size (int): The new page size.

        Returns:
            str: The URL for the new page size.
        """
        if size == Pagination.DEFAULT_PAGE_SIZE:
            return str(base_url)
        return str(base_url.clone().add("page-size", str(size)))

    class PageSizeOption:
        """A page size option.

        Args:
            size: The page size.
            url: The URL of this page size.

        Attributes:
            size (int): The page size.
            url (str): The URL for this page size.
        """
        def __init__(self, size: int, url: str):
            self.size = size
            self.url = url


class PaginationException(Exception):
    """The exception thrown with invalid pagination parameters.

    Args:
        url_builder: The canonical URL to redirect to.

    Attributes:
        url (str): The canonical URL to redirect to.
    """
    def __init__(self, url_builder: UrlBuilder):
        self.url = str(url_builder)


CDN_LIBRARIES = {
    "jquery": {"css": [],
               "js": ["https://code.jquery.com/jquery-3.5.1.min.js"]},
    "bootstrap4": {
        "css": [("https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/"
                 "bootstrap.min.css")],
        "js": [("https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/"
                "popper.min.js"),
               ("https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/"
                "bootstrap.min.js")]},
    "font-awesome-5": {
        "css": ["https://use.fontawesome.com/releases/v5.14.0/css/all.css"],
        "js": []},
    "bootstrap4-datatables": {
        "css": [("https://cdn.datatables.net/1.10.21/css/"
                 "jquery.dataTables.min.css"),
                ("https://cdn.datatables.net/1.10.21/css/"
                 "dataTables.bootstrap4.min.css")],
        "js": [("https://cdn.datatables.net/1.10.21/js/"
                "jquery.dataTables.min.js"),
               ("https://cdn.datatables.net/1.10.21/js/"
                "dataTables.bootstrap4.min.js")]},
    "jquery-ui": {"css": [("https://cdnjs.cloudflare.com/ajax/libs/jqueryui/"
                           "1.12.1/jquery-ui.min.css")],
                  "js": [("https://cdnjs.cloudflare.com/ajax/libs/jqueryui/"
                          "1.12.1/jquery-ui.min.js")]},
    "bootstrap4-tempusdominus": {
        "css": [("https://cdnjs.cloudflare.com/ajax/libs/"
                 "tempusdominus-bootstrap-4/5.1.2/css/"
                 "tempusdominus-bootstrap-4.min.css")],
        "js": [("https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.27.0/"
                "moment-with-locales.min.js"),
               ("https://cdnjs.cloudflare.com/ajax/libs/"
                "tempusdominus-bootstrap-4/5.1.2/js/"
                "tempusdominus-bootstrap-4.js")]},
    "decimal-js": {"css": [],
                   "js": [("https://cdnjs.cloudflare.com/ajax/libs/decimal.js/"
                           "10.2.0/decimal.min.js")]},
    "period-chooser": {"css": ["mia_core/css/period-chooser.css"],
                       "js": ["mia_core/js/period-chooser.js"]}
}
DEFAULT_LIBS = []


class CssAndJavaScriptLibraries:
    """The CSS and JavaScript library resolver."""
    AVAILABLE_LIBS: List[str] = ["jquery", "bootstrap4", "font-awesome-5",
                                 "bootstrap4-datatables", "jquery-ui",
                                 "bootstrap4-tempusdominus", "decimal-js",
                                 "i18n", "period-chooser"]

    def __init__(self, *args):
        self._use: Dict[str, bool] = {x: False for x in self.AVAILABLE_LIBS}
        self._add_default_libs()
        # The specified libraries
        if len(args) > 0:
            libs = args[0]
            invalid = [x for x in libs if x not in self.AVAILABLE_LIBS]
            if len(invalid) > 0:
                raise NameError("library %s invalid" % ", ".join(invalid))
            for lib in libs:
                self._use[lib] = True
        self._css = []
        try:
            self._css = self._css + settings.DEFAULT_CSS
        except AttributeError:
            pass
        self._js = []
        try:
            self._css = self._css + settings.DEFAULT_JS
        except AttributeError:
            pass

    def _add_default_libs(self):
        """Adds the default libraries."""
        invalid = [x for x in DEFAULT_LIBS if x not in self.AVAILABLE_LIBS]
        if len(invalid) > 0:
            raise NameError("library %s invalid" % ", ".join(invalid))
        for lib in DEFAULT_LIBS:
            self._use[lib] = True

    def use(self, *args) -> None:
        """Use the specific libraries.

        Args:
            args: The libraries.
        """
        if len(args) == 0:
            return
        libs = args[0]
        invalid = [x for x in libs if x not in self.AVAILABLE_LIBS]
        if len(invalid) > 0:
            raise NameError("library %s invalid" % ", ".join(invalid))
        for lib in libs:
            self._use[lib] = True

    def add_css(self, css) -> None:
        """Adds a custom CSS file."""
        self._css.append(css)

    def add_js(self, js) -> None:
        """Adds a custom JavaScript file."""
        self._js.append(js)

    def css(self) -> List[str]:
        """Returns the stylesheet files to use."""
        use: Dict[str, bool] = self._solve_use_dependencies()
        css = []
        for lib in [x for x in self.AVAILABLE_LIBS if use[x]]:
            if lib == "i18n":
                continue
            try:
                css = css + settings.STATIC_LIBS[lib]["css"]
            except AttributeError:
                css = css + CDN_LIBRARIES[lib]["css"]
            except TypeError:
                css = css + CDN_LIBRARIES[lib]["css"]
            except KeyError:
                css = css + CDN_LIBRARIES[lib]["css"]
        return css + self._css

    def js(self) -> List[str]:
        """Returns the JavaScript files to use."""
        use: Dict[str, bool] = self._solve_use_dependencies()
        js = []
        for lib in [x for x in self.AVAILABLE_LIBS if use[x]]:
            if lib == "i18n":
                js.append(reverse("javascript-catalog"))
                continue
            try:
                js = js + settings.STATIC_LIBS[lib]["js"]
            except AttributeError:
                js = js + CDN_LIBRARIES[lib]["js"]
            except TypeError:
                js = js + CDN_LIBRARIES[lib]["js"]
            except KeyError:
                js = js + CDN_LIBRARIES[lib]["js"]
        return js + self._js

    def _solve_use_dependencies(self) -> Dict[str, bool]:
        """Solves and returns the library dependencies."""
        use: Dict[str, bool] = {x: self._use[x] for x in self._use}
        if use["period-chooser"]:
            use["bootstrap4-tempusdominus"] = True
        if use["bootstrap4-tempusdominus"]:
            use["bootstrap4"] = True
        if use["bootstrap4-datatables"]:
            use["bootstrap4"] = True
        if use["jquery-ui"]:
            use["jquery"] = True
        if use["bootstrap4"]:
            use["jquery"] = True
        return use


def add_default_libs(*args) -> None:
    """Adds the specified libraries to the default CSS and JavaScript
    libraries.

    Args:
        args: The libraries to be added to the default libraries
    """
    libs = args
    invalid = [x for x in libs
               if x not in CssAndJavaScriptLibraries.AVAILABLE_LIBS]
    if len(invalid) > 0:
        raise NameError("library %s invalid" % ", ".join(invalid))
    for lib in libs:
        if lib not in DEFAULT_LIBS:
            DEFAULT_LIBS.append(lib)
