import abc
import copy
import logging
import re
from os.path import exists
from typing import Any, Dict, List, Optional, Tuple, Union

from uaclient import (
    api,
    apt,
    contract,
    event_logger,
    exceptions,
    http,
    messages,
    secret_manager,
    system,
    util,
)
from uaclient.entitlements import base
from uaclient.entitlements.entitlement_status import (
    ApplicationStatus,
    CanDisableFailure,
    CanDisableFailureReason,
)
from uaclient.files.state_files import status_cache_file

event = event_logger.get_event_logger()
LOG = logging.getLogger(util.replace_top_level_logger_name(__name__))

# See 'What does a specific Ubuntu kernel version number mean?' in
# https://wiki.ubuntu.com/Kernel/FAQ
RE_KERNEL_PKG = r"^linux-image-([\d]+[.-][\d]+[.-][\d]+-[\d]+-[A-Za-z0-9_-]+)$"


class RepoEntitlement(base.UAEntitlement):
    repo_file_tmpl = "/etc/apt/sources.list.d/ubuntu-{name}.{extension}"
    repo_pref_file_tmpl = "/etc/apt/preferences.d/ubuntu-{name}"
    repo_url_tmpl = "{}/ubuntu"

    # The repo Origin value defined in apt metadata
    origin = None  # type: Optional[str]

    # GH: #1084 call apt in noninteractive mode
    apt_noninteractive = False

    # Check if the requested packages are installed to inform if
    # the service is enabled or not
    check_packages_are_installed = False

    # RepoEntitlements can be purged, unless specifically stated
    supports_purge = True

    # Optional repo pin priority in subclass
    @property
    def repo_pin_priority(self) -> Union[int, str, None]:
        return None

    @property
    def repo_file(self) -> str:
        extension = "sources"
        series = system.get_release_info().series
        if series in apt.SERIES_NOT_USING_DEB822:
            extension = "list"
        return self.repo_file_tmpl.format(name=self.name, extension=extension)

    @property
    def repo_policy_check_tmpl(self) -> str:
        return self.repo_url_tmpl + " {}"

    @property
    def packages(self) -> List[str]:
        """debs to install on enablement"""
        packages = []

        entitlement = self.entitlement_cfg.get("entitlement", {})

        if entitlement:
            directives = entitlement.get("directives", {})
            additional_packages = copy.copy(
                directives.get("additionalPackages", [])
            )

            packages = additional_packages

        return packages

    @property
    def apt_url(self) -> Optional[str]:
        return (
            self.entitlement_cfg.get("entitlement", {})
            .get("directives", {})
            .get("aptURL")
        )

    @property
    def snapshot_urls(self) -> List[str]:
        return (
            self.entitlement_cfg.get("entitlement", {})
            .get("directives", {})
            .get("aptSnapshotURLs", [])
        )

    @property
    def apt_suites(self) -> Optional[str]:
        return (
            self.entitlement_cfg.get("entitlement", {})
            .get("directives", {})
            .get("suites")
        )

    def get_resource_token(self) -> str:
        token = self.entitlement_cfg.get("resourceToken")
        if not token:
            machine_token = self.machine_token_file.machine_token[
                "machineToken"
            ]
            obligations = self.entitlement_cfg["entitlement"].get(
                "obligations", {}
            )
            if not obligations.get("enableByDefault"):
                # services that are not enableByDefault need to obtain specific
                # resource access for tokens. We want to refresh this every
                # enable call because it is not refreshed by `pro refresh`.
                client = contract.UAContractClient(self.cfg)
                machine_access = client.get_resource_machine_access(
                    machine_token, self.name
                )
                if machine_access:
                    token = machine_access.get("resourceToken")
            if not token:
                token = machine_token
                LOG.warning(
                    "No resourceToken present in contract for service %s."
                    " Using machine token as credentials",
                    self.title,
                )
        return token

    def _check_for_reboot(self) -> bool:
        """Check if system needs to be rebooted."""
        reboot_required = system.should_reboot(
            installed_pkgs=set(self.packages)
        )
        event.needs_reboot(reboot_required)
        return reboot_required

    @property
    @abc.abstractmethod
    def repo_key_file(self) -> str:
        pass

    def can_disable(
        self, ignore_dependent_services: bool = False
    ) -> Tuple[bool, Optional[CanDisableFailure]]:
        result, reason = super().can_disable(
            ignore_dependent_services=ignore_dependent_services
        )
        if result is False:
            return result, reason

        if not self.origin and self.purge:
            return (
                False,
                CanDisableFailure(
                    CanDisableFailureReason.NO_PURGE_WITHOUT_ORIGIN,
                    messages.REPO_PURGE_FAIL_NO_ORIGIN.format(
                        entitlement_name=self.title, title=self.title
                    ),
                ),
            )

        return result, reason

    def enable_steps(self) -> int:
        will_install = self.packages is not None and len(self.packages) > 0
        if self.access_only or not will_install:
            # 1. Configure APT
            # 2. Update APT lists
            return 2
        else:
            # 3. Install packages
            return 3

    def _perform_enable(self, progress: api.ProgressWrapper) -> bool:
        """Enable specific entitlement.

        @return: True on success, False otherwise.
        @raises: UbuntuProError on failure to install suggested packages
        """
        progress.progress(
            messages.CONFIGURING_APT_ACCESS.format(service=self.title)
        )
        self.setup_apt_config(progress)

        if self.supports_access_only and self.access_only:
            if len(self.packages) > 0:
                progress.emit(
                    "info",
                    messages.SKIPPING_INSTALLING_PACKAGES.format(
                        packages=" ".join(self.packages)
                    ),
                )
        else:
            self.install_packages(progress)
        return True

    def disable_steps(self) -> int:
        if not self.purge:
            # 1. Unconfigure APT
            # 2. Update package lists
            return 2
        else:
            # 3. Purge
            return 3

    def _perform_disable(self, progress: api.ProgressWrapper):
        if self.purge and self.origin:
            progress.emit("info", messages.PURGE_EXPERIMENTAL)
            progress.emit("info", "")

            repo_origin_packages = apt.get_installed_packages_by_origin(
                self.origin
            )

            if not self.purge_kernel_check(repo_origin_packages, progress):
                return False

            packages_to_reinstall = []
            packages_to_remove = []
            for package in repo_origin_packages:
                alternatives = apt.get_remote_versions_for_package(
                    package, exclude_origin=self.origin
                )
                if alternatives:
                    # We can call max(List[Version]) but mypy doesn't know.
                    # Or doesn't like it.
                    packages_to_reinstall.append(
                        (package, max(alternatives))  # type: ignore
                    )
                else:
                    packages_to_remove.append(package)

            if not self.prompt_for_purge(
                packages_to_remove, packages_to_reinstall, progress
            ):
                return False

        if hasattr(self, "remove_packages"):
            self.remove_packages()
        self.remove_apt_config(progress)

        if self.purge and self.origin:
            progress.progress(
                messages.PURGING_PACKAGES.format(title=self.title)
            )
            self.execute_reinstall(packages_to_reinstall)
            self.execute_removal(packages_to_remove)
        return True

    def purge_kernel_check(self, package_list, progress: api.ProgressWrapper):
        """
        Checks if the purge operation involves a kernel.

        When package called 'linux-image-*' is in the package list, warn the
        user that a kernel is being removed. Then, show the user what the
        current kernel is.

        If the current kernel is to be removed, and there are no other valid
        Ubuntu Kernels installed in the system, return False to abort the
        operation.

        If there is another Ubuntu kernel - besides the one installed - then
        prompt the user for confirmation before proceeding.
        """
        linux_image_versions = []
        for package in package_list:
            m = re.search(RE_KERNEL_PKG, package.name)
            if m:
                linux_image_versions.append(m.group(1))
        if linux_image_versions:
            # A kernel needs to be removed to purge
            # API will fail here, but we want CLI to allow it to continue
            # after a prompt
            if not progress.is_interactive():
                raise exceptions.NonInteractiveKernelPurgeDisallowed()

            progress.emit(
                "info",
                messages.PURGE_KERNEL_REMOVAL.format(service=self.title),
            )
            progress.emit("info", " ".join(linux_image_versions))

            current_kernel = system.get_kernel_info().uname_release
            progress.emit(
                "info",
                messages.PURGE_CURRENT_KERNEL.format(
                    kernel_version=current_kernel
                ),
            )

            installed_kernels = system.get_installed_ubuntu_kernels()
            # Any installed Ubuntu Kernel not being touched in this operation
            alternative_kernels = [
                version
                for version in installed_kernels
                if version not in linux_image_versions
            ]

            if not alternative_kernels:
                progress.emit("info", messages.PURGE_NO_ALTERNATIVE_KERNEL)
                return False

            progress.emit(
                "message_operation",
                [
                    (
                        util.prompt_for_confirmation,
                        {"msg": messages.PURGE_KERNEL_CONFIRMATION},
                    )
                ],
            )

        return True

    def prompt_for_purge(
        self,
        packages_to_remove,
        packages_to_reinstall,
        progress: api.ProgressWrapper,
    ):
        prompt = False
        if packages_to_remove:
            progress.emit("info", messages.WARN_PACKAGES_REMOVAL)
            progress.emit(
                "info",
                util.create_package_list_str(
                    [package.name for package in packages_to_remove]
                ),
            )
            prompt = True

        if packages_to_reinstall:
            progress.emit("info", messages.WARN_PACKAGES_REINSTALL)
            progress.emit(
                "info",
                util.create_package_list_str(
                    [package.name for (package, _) in packages_to_reinstall]
                ),
            )
            prompt = True

        if prompt:
            progress.emit(
                "message_operation",
                [
                    (
                        util.prompt_for_confirmation,
                        {"msg": messages.PROCEED_YES_NO},
                    )
                ],
            )
        return True

    def execute_removal(self, packages_to_remove):
        # We need to check again if the package is installed, because there is
        # an intermediate step between listing the packages and acting on them.
        # Some reinstalls may also uninstall dependencies.
        # Packages may be removed between those operations.
        installed_packages = apt.get_installed_packages_names()
        to_remove = [
            package.name
            for package in packages_to_remove
            if package.name in installed_packages
        ]
        if to_remove:
            apt.purge_packages(
                to_remove,
                messages.UNINSTALLING_PACKAGES_FAILED.format(
                    packages=to_remove
                ),
            )

    def execute_reinstall(self, packages_to_reinstall):
        # We need to check again if the package is installed, because there is
        # an intermediate step between listing the packages and acting on them.
        # Packages may be removed between those operations.
        installed_packages = apt.get_installed_packages_names()
        to_reinstall = [
            "{}={}".format(package.name, version.ver_str)
            for (package, version) in packages_to_reinstall
            if package.name in installed_packages
        ]
        if to_reinstall:
            apt.reinstall_packages(to_reinstall)

    def application_status(
        self,
    ) -> Tuple[ApplicationStatus, Optional[messages.NamedMessage]]:
        current_status = (
            ApplicationStatus.DISABLED,
            messages.SERVICE_NOT_CONFIGURED.format(title=self.title),
        )

        entitlement_cfg = self.entitlement_cfg
        directives = entitlement_cfg.get("entitlement", {}).get(
            "directives", {}
        )
        repo_url = directives.get("aptURL")
        if not repo_url:
            return (
                ApplicationStatus.DISABLED,
                messages.NO_APT_URL_FOR_SERVICE.format(title=self.title),
            )
        repo_suites = directives.get("suites")
        if not repo_suites:
            return (
                ApplicationStatus.DISABLED,
                messages.NO_SUITES_FOR_SERVICE.format(title=self.title),
            )

        policy = apt.get_apt_cache_policy(error_msg=messages.APT_POLICY_FAILED)
        for suite in repo_suites:
            service_match = re.search(
                self.repo_policy_check_tmpl.format(repo_url, suite), policy
            )
            if service_match:
                current_status = (
                    ApplicationStatus.ENABLED,
                    messages.SERVICE_IS_ACTIVE.format(title=self.title),
                )
                break

        if self.check_packages_are_installed:
            for package in self.packages:
                if not apt.is_installed(package):
                    return (
                        ApplicationStatus.DISABLED,
                        messages.SERVICE_DISABLED_MISSING_PACKAGE.format(
                            service=self.name, package=package
                        ),
                    )

        return current_status

    def _check_apt_url_is_applied(self, apt_url):
        """Check if apt url delta should be applied.

        :param apt_url: string containing the apt url to be used.

        :return: False if apt url is already found on the source file.
                 True otherwise.
        """
        apt_file = self.repo_file
        # If the apt file is commented out, we will assume that we need
        # to regenerate the apt file, regardless of the apt url delta
        if all(
            line.startswith("#")
            for line in system.load_file(apt_file).strip().split("\n")
        ):
            return False

        # If the file is not commented out and we don't have delta,
        # we will not do anything
        if not apt_url:
            return True

        # If the delta is already in the file, we won't reconfigure it
        # again
        return bool(apt_url in system.load_file(apt_file))

    def process_contract_deltas(
        self,
        orig_access: Dict[str, Any],
        deltas: Dict[str, Any],
        allow_enable: bool = False,
    ) -> bool:
        """Process any contract access deltas for this entitlement.

        :param orig_access: Dictionary containing the original
            resourceEntitlement access details.
        :param deltas: Dictionary which contains only the changed access keys
        and values.
        :param allow_enable: Boolean set True if allowed to perform the enable
            operation. When False, a message will be logged to inform the user
            about the recommended enabled service.

        :return: True when delta operations are processed; False when noop.
        """
        if super().process_contract_deltas(orig_access, deltas, allow_enable):
            return True  # Already processed parent class deltas

        delta_entitlement = deltas.get("entitlement", {})
        delta_directives = delta_entitlement.get("directives", {})
        delta_apt_url = delta_directives.get("aptURL")
        delta_packages = delta_directives.get("additionalPackages")
        status_cache = status_cache_file.read()

        if delta_directives and status_cache:
            application_status = self._check_application_status_on_cache()
        else:
            application_status, _ = self.application_status()

        if application_status == ApplicationStatus.DISABLED:
            return False

        if not self._check_apt_url_is_applied(delta_apt_url):
            LOG.info(
                "New aptURL, updating %s apt sources list to %s",
                self.name,
                delta_apt_url,
            )
            event.info(
                messages.REPO_UPDATING_APT_SOURCES.format(service=self.name)
            )

            orig_entitlement = orig_access.get("entitlement", {})
            old_url = orig_entitlement.get("directives", {}).get("aptURL")
            if old_url:
                # Remove original aptURL and auth and rewrite
                apt.remove_auth_apt_repo(
                    self.repo_file, old_url, self.snapshot_urls
                )

            self.remove_apt_config(api.ProgressWrapper())
            self.setup_apt_config(api.ProgressWrapper())

        if delta_packages:
            LOG.info("New additionalPackages, installing %r", delta_packages)
            event.info(
                messages.REPO_REFRESH_INSTALLING_PACKAGES.format(
                    packages=", ".join(delta_packages)
                )
            )
            self.install_packages(
                api.ProgressWrapper(), package_list=delta_packages
            )

        return True

    def install_packages(
        self,
        progress: api.ProgressWrapper,
        package_list: Optional[List[str]] = None,
        cleanup_on_failure: bool = True,
    ) -> None:
        """Install contract recommended packages for the entitlement.

        :param package_list: Optional package list to use instead of
            self.packages.
        :param cleanup_on_failure: Cleanup apt files if apt install fails.
        """

        if not package_list:
            package_list = self.packages

        if not package_list:
            return

        progress.emit("message_operation", self.messaging.get("pre_install"))

        try:
            self._update_sources_list(progress)
        except exceptions.UbuntuProError:
            if cleanup_on_failure:
                self.remove_apt_config(api.ProgressWrapper())
            raise

        progress.progress(
            messages.INSTALLING_SERVICE_PACKAGES.format(title=self.title)
        )

        if self.apt_noninteractive:
            override_env_vars = {"DEBIAN_FRONTEND": "noninteractive"}
            apt_options = [
                "--allow-downgrades",
                '-o Dpkg::Options::="--force-confdef"',
                '-o Dpkg::Options::="--force-confold"',
            ]
        else:
            override_env_vars = None
            apt_options = []

        try:
            apt.run_apt_install_command(
                packages=package_list,
                apt_options=apt_options,
                override_env_vars=override_env_vars,
            )
        except exceptions.UbuntuProError:
            if cleanup_on_failure:
                LOG.info(
                    "Apt install failed, removing apt config for {}".format(
                        self.name
                    )
                )
                self.remove_apt_config(api.ProgressWrapper())
            raise

    def setup_apt_config(self, progress: api.ProgressWrapper) -> None:
        """Setup apt config based on the resourceToken and directives.
        Also sets up apt proxy if necessary.

        :raise UbuntuProError: on failure to setup any aspect of this apt
           configuration
        """
        http_proxy = None  # type: Optional[str]
        https_proxy = None  # type: Optional[str]
        scope = None  # type: Optional[apt.AptProxyScope]
        if self.cfg.global_apt_http_proxy or self.cfg.global_apt_https_proxy:
            http_proxy = http.validate_proxy(
                "http",
                self.cfg.global_apt_http_proxy,
                http.PROXY_VALIDATION_APT_HTTP_URL,
            )
            https_proxy = http.validate_proxy(
                "https",
                self.cfg.global_apt_https_proxy,
                http.PROXY_VALIDATION_APT_HTTPS_URL,
            )
            scope = apt.AptProxyScope.GLOBAL
        elif self.cfg.ua_apt_http_proxy or self.cfg.ua_apt_https_proxy:
            http_proxy = http.validate_proxy(
                "http",
                self.cfg.ua_apt_http_proxy,
                http.PROXY_VALIDATION_APT_HTTP_URL,
            )
            https_proxy = http.validate_proxy(
                "https",
                self.cfg.ua_apt_https_proxy,
                http.PROXY_VALIDATION_APT_HTTPS_URL,
            )
            scope = apt.AptProxyScope.UACLIENT

        apt.setup_apt_proxy(
            http_proxy=http_proxy, https_proxy=https_proxy, proxy_scope=scope
        )
        repo_filename = self.repo_file
        resource_cfg = self.entitlement_cfg
        directives = resource_cfg["entitlement"].get("directives", {})
        token = self.get_resource_token()
        aptKey = directives.get("aptKey")
        if not aptKey:
            raise exceptions.RepoNoAptKey(entitlement_name=self.name)
        repo_url = directives.get("aptURL")
        if not repo_url:
            raise exceptions.MissingAptURLDirective(entitlement_name=self.name)
        repo_suites = directives.get("suites")
        if not repo_suites:
            raise exceptions.RepoNoSuites(entitlement_name=self.name)
        if self.repo_pin_priority:
            if not self.origin:
                raise exceptions.RepoPinFailNoOrigin(
                    entitlement_name=self.name,
                    title=self.title,
                )
            repo_pref_file = self.repo_pref_file_tmpl.format(name=self.name)
            apt.add_ppa_pinning(
                repo_pref_file,
                repo_url,
                self.origin,
                self.repo_pin_priority,
            )

        prerequisite_pkgs = []
        if not exists(apt.APT_METHOD_HTTPS_FILE):
            prerequisite_pkgs.append("apt-transport-https")
        if not exists(apt.CA_CERTIFICATES_FILE):
            prerequisite_pkgs.append("ca-certificates")

        if prerequisite_pkgs:
            progress.emit(
                "info",
                messages.INSTALLING_PACKAGES.format(
                    packages=", ".join(prerequisite_pkgs)
                ),
            )
            try:
                apt.run_apt_install_command(packages=prerequisite_pkgs)
            except exceptions.UbuntuProError:
                self.remove_apt_config(api.ProgressWrapper())
                raise
        apt.add_auth_apt_repo(
            repo_filename,
            self.repo_url_tmpl.format(repo_url),
            token,
            repo_suites,
            self.repo_key_file,
            self.snapshot_urls,
        )
        # Run apt-update on any repo-entitlement enable because the machine
        # probably wants access to the repo that was just enabled.
        # Side-effect is that apt policy will now report the repo as accessible
        # which allows pro status to report correct info
        progress.progress(messages.APT_UPDATING_LIST.format(name=self.title))
        try:
            apt.update_sources_list(repo_filename)
        except exceptions.UbuntuProError:
            self.remove_apt_config(api.ProgressWrapper(), run_apt_update=False)
            raise

    def update_apt_auth(
        self, override_snapshot_urls: Optional[List[str]] = None
    ):
        if override_snapshot_urls is not None:
            snapshot_urls = override_snapshot_urls
        else:
            snapshot_urls = self.snapshot_urls
        credentials = self.get_resource_token()
        repo_url = (
            self.entitlement_cfg.get("entitlement", {})
            .get("directives", {})
            .get("aptURL")
        )
        repo_url = self.repo_url_tmpl.format(repo_url)
        try:
            username, password = credentials.split(":")
        except ValueError:  # Then we have a bearer token
            username = "bearer"
            password = credentials
        secret_manager.secrets.add_secret(password)
        for url in [repo_url] + snapshot_urls:
            if url:
                apt.add_apt_auth_conf_entry(url, username, password)

    def remove_apt_config(
        self,
        progress: api.ProgressWrapper,
        run_apt_update: bool = True,
    ):
        """Remove any repository apt configuration files.

        :param run_apt_update: If after removing the apt update
            command after removing the apt files.
        """
        series = system.get_release_info().series
        repo_filename = self.repo_file
        entitlement = self.machine_token_file.entitlements()[self.name].get(
            "entitlement", {}
        )
        access_directives = entitlement.get("directives", {})
        repo_url = access_directives.get("aptURL")
        if not repo_url:
            raise exceptions.MissingAptURLDirective(entitlement_name=self.name)

        repo_url = self.repo_url_tmpl.format(repo_url)

        progress.progress(
            messages.REMOVING_APT_CONFIGURATION.format(title=self.title)
        )
        apt.remove_auth_apt_repo(
            repo_filename, repo_url, self.snapshot_urls, self.repo_key_file
        )
        apt.remove_apt_list_files(repo_url, series)

        if self.repo_pin_priority:
            repo_pref_file = self.repo_pref_file_tmpl.format(name=self.name)
            system.ensure_file_absent(repo_pref_file)

        if run_apt_update:
            progress.progress(messages.APT_UPDATING_LISTS)
            apt.run_apt_update_command()
