"""
Copyright (c) 2023 Proton AG

This file is part of Proton VPN.

Proton VPN is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

Proton VPN is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with ProtonVPN.  If not, see <https://www.gnu.org/licenses/>.
"""
import re
from importlib import metadata
from typing import Optional

import time
import random
import os as sys_os
import json
from dataclasses import asdict
import zoneinfo
import distro
from packaging.version import Version
from proton.vpn import logging

logger = logging.getLogger(__name__)

LOCALTIME_PATH = "/etc/localtime"


def get_local_timezone(localtime_path: str = LOCALTIME_PATH) -> Optional[str]:
    """
    Returns the local IANA timezone name (e.g. "Europe/Zurich"), or None if it
    can't be determined.

    zoneinfo can load a zone by name but not report which one is local, and the tzdata
    file does not contain its own name. Name only exists as the path `/etc/localtime`
    points to, so it is read from there. Path is a symlink on supported distros.

    No extra checks or third-party packages to avoid false positives. Other packages
    read the TZ env var first, and some return ambigious timezones when nothing is
    resolved (UTC). Wrong timezone is worse than None.

    Returns the IANA timezone, None when can't be resolved.

    """
    path = sys_os.path.realpath(localtime_path)
    for root in zoneinfo.TZPATH:
        prefix = root + sys_os.sep
        if path.startswith(prefix):
            # tzdata ships copies of the whole database under posix/ and right/.
            # Those directories are not part of the zone name.
            return path[len(prefix):].removeprefix("posix/").removeprefix("right/")

    return None


class Serializable:  # pylint: disable=missing-class-docstring
    """Utility class for dataclasses."""
    def to_json(self) -> str:  # pylint: disable=missing-function-docstring
        return json.dumps(asdict(self))

    def to_dict(self) -> dict:  # pylint: disable=missing-function-docstring
        return asdict(self)

    @classmethod
    def from_dict(cls, dict_data: dict) -> 'Serializable':  # noqa: E501 pylint: disable=missing-function-docstring
        return cls._deserialize(dict_data)

    @classmethod
    def from_json(cls, data: str) -> 'Serializable':  # pylint: disable=missing-function-docstring
        dict_data = json.loads(data)
        return cls._deserialize(dict_data)

    @staticmethod
    def _deserialize(dict_data: dict) -> 'Serializable':
        raise NotImplementedError


class RefreshCalculator:
    """Calculates refresh times based on a set refresh randomness value."""
    def __init__(
        self,
        refresh_interval: int,
        refresh_randomness_in_percentage: float = None
    ):
        """
        The variable refresh_randomness_in_percentage will be used to create a
        deviation from original refresh value.
        Ie: 0.22 == 22% variation, so if we make request every 3h they will
        happen with random deviation between 0% and 22% from the base 3h value.
        """
        self._refresh_interval = refresh_interval
        self._refresh_randomness = refresh_randomness_in_percentage or 0.22

    @staticmethod
    def get_is_expired(expiration_time: float) -> bool:
        """Returns if data has expired"""
        current_time = time.time()
        return current_time > expiration_time

    @staticmethod
    def get_seconds_until_expiration(expiration_time: float) -> float:
        """
        Amount of seconds left until the client configuration is considered
        outdated and should be fetched again from the REST API.
        """
        seconds_left = expiration_time - time.time()
        return seconds_left if seconds_left > 0 else 0

    @staticmethod
    def get_expiration_time(
        refresh_interval: int,
        refresh_randomness: float = None,
        start_time: float = None
    ) -> float:  # noqa: E501 pylint: disable=missing-function-docstring
        """Returns the expiration time based on either a defined start time or current time."""
        start_time = start_time if start_time is not None else time.time()
        refresh_calculator = RefreshCalculator(refresh_interval, refresh_randomness)

        return start_time + refresh_calculator.get_refresh_interval_in_seconds()

    def get_refresh_interval_in_seconds(self) -> float:  # noqa pylint: disable=missing-function-docstring
        return self._refresh_interval * self._generate_random_component()

    def _generate_random_component(self):
        return 1 + self._refresh_randomness * (2 * random.random() - 1)  # nosec B311 # noqa: E501 # pylint: disable=line-too-long # nosemgrep: gitlab.bandit.B311


async def rest_api_request(session, route, **api_request_kwargs):  # noqa: E501 pylint: disable=missing-function-docstring
    # The request is logged by VPNSession.async_api_request, which sees all of them.
    response = await session.async_api_request(
        route, **api_request_kwargs
    )
    logger.info(f"'{route}'", category="api", event="response")
    return response


def to_semver_build_metadata_format(value: Optional[str]) -> Optional[str]:
    """
    Formats the input value in a format that complies with
    semver's build metadata specs (https://semver.org/#spec-item-10).
    """
    if value is None:
        return None

    value = value.replace("_", "-")
    # Any character not allowed by semver's build metadata suffix
    # specs (https://semver.org/#spec-item-10) is removed.
    value = re.sub(r"[^a-zA-Z0-9\-]", "", value)
    return value


def get_desktop_environment() -> str:
    """Returns the current desktop environment"""
    return sys_os.environ.get('XDG_CURRENT_DESKTOP', "Unknown DE")


def get_distro_variant() -> str:
    """Returns the current distro environment"""
    distro_variant = distro.os_release_attr('variant')
    return f"; {distro_variant}" if distro_variant else ""


def get_distro_version() -> str:
    """Returns the string containing the distro version:
    ie:
     - Fedora: "39"/"40"
    """
    return distro.version()


def semver_from_pep440(pep440_version: str) -> str:
    """
    Converts a PEP440 version to a semver version.

    Disclaimers:
    - It assumes the PEP440 version contains the major, minor, micro triplet (e.g. 1.2.3).
    - Date-based releases are not supported (e.g. 2023.05).
    - Post release segments are not supported, since semver doesn't allow them.

    https://peps.python.org/pep-0440
    https://semver.org
    """
    ver = Version(pep440_version)

    # Even though PEP440 doesn't require it, our versions always contain
    # the major, minor, and micro triplet.
    result = f"{ver.major}.{ver.minor}.{ver.micro}"

    if ver.pre is not None:
        prerelease_mappings = {
            "a": "alpha",
            "b": "beta",
            "rc": "rc"
        }
        result += f"-{prerelease_mappings[ver.pre[0]]}.{ver.pre[1]}"

    if ver.dev is not None:
        result += f"-dev.{ver.dev}"

    if ver.local is not None:
        result += f"+{ver.local}"

    return result


def get_core_api_semver_version() -> str:
    """
    Converts the PEP440 version of this python package to the equivalent semver version.
    """
    return semver_from_pep440(metadata.version("proton-vpn-api-core"))


def generate_os_string() -> str:
    """Returns a string which contains information such as the distro, desktop environment
    and distro variant if it exists"""
    return f"{distro.id()} ({get_desktop_environment()}{get_distro_variant()})"
