import json
import logging
import os
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import List, Dict

import kubernetes
import voluptuous as vol
from azure.mgmt.containerservice.container_service_client import ContainerServiceClient
from azure.mgmt.containerservice.models import CredentialResults
from kubernetes.client import CoreV1Api

from takeoff.application_version import ApplicationVersion
from takeoff.azure.credentials.active_directory_user import ActiveDirectoryUserCredentials
from takeoff.azure.credentials.keyvault import KeyVaultClient
from takeoff.azure.credentials.keyvault_credentials_provider import KeyVaultCredentialsMixin
from takeoff.azure.credentials.subscription_id import SubscriptionId
from takeoff.azure.util import get_resource_group_name, get_kubernetes_name
from takeoff.context import Context, ContextKey
from takeoff.credentials.container_registry import DockerRegistry
from takeoff.credentials.secret import Secret
from takeoff.schemas import TAKEOFF_BASE_SCHEMA
from takeoff.step import Step
from takeoff.util import b64_encode, ensure_base64, render_string_with_jinja, run_shell_command

logger = logging.getLogger(__name__)


class BaseKubernetes(Step):
    """Base Kubernetes class

    This class is used by the two Kubernetes steps: deploy_to_kubernetes and kubernetes_image_rolling_update.
    It handles the authentication to the specified Kubernetes cluster

    Depends on:
    - Credentials for the Kubernetes cluster (username, password) must be available in your cloud vault
    """

    def __init__(self, env: ApplicationVersion, config: dict):
        super().__init__(env, config)
        self.vault_name, self.vault_client = KeyVaultClient.vault_and_client(self.config, self.env)

    @staticmethod
    def _write_kube_config(credential_results: CredentialResults):
        """Creates ~/.kube/config and writes the credentials for the Kubernetes cluster to the file

        Args:
            credential_results: the cluster credentials for the cluster
        """
        kubeconfig = credential_results.kubeconfigs[0].value.decode(encoding="UTF-8")

        kubeconfig_dir = Path(os.environ["HOME"]) / ".kube"

        kubeconfig_dir.mkdir(exist_ok=True)

        with open(kubeconfig_dir / "config", "w") as f:
            f.write(kubeconfig)

        logger.info("Kubeconfig successfully written")

    def _authenticate_with_kubernetes(self):
        """Authenticate with the defined AKS cluster and write the configuration to a file"""
        resource_group = get_resource_group_name(self.config, self.env)
        cluster_name = get_kubernetes_name(self.config, self.env)

        # get azure container service client
        credentials = ActiveDirectoryUserCredentials(
            vault_name=self.vault_name, vault_client=self.vault_client
        ).credentials(self.config)

        client = ContainerServiceClient(
            credentials=credentials,
            subscription_id=SubscriptionId(self.vault_name, self.vault_client).subscription_id(self.config),
        )

        # authenticate with Kubernetes
        credential_results = client.managed_clusters.list_cluster_user_credentials(
            resource_group_name=resource_group, resource_name=cluster_name
        )

        self._write_kube_config(credential_results)


IP_ADDRESS_MATCH = r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"
DEPLOY_SCHEMA = TAKEOFF_BASE_SCHEMA.extend(
    {
        vol.Required("task"): "deploy_to_kubernetes",
        vol.Optional("credentials", default="environment_variables"): vol.All(
            str, vol.In(["environment_variables", "azure_keyvault"])
        ),
        vol.Required("kubernetes_config_path"): str,
        vol.Optional(
            "image_pull_secret",
            default={"create": True, "secret_name": "registry-auth", "namespace": "default"},
        ): {
            vol.Optional("create", default=True): bool,
            vol.Optional("secret_name", default="registry-auth"): str,
            vol.Optional("namespace", default="default"): str,
        },
        vol.Optional("custom_values", default={}): {},
        vol.Optional("restart_unchanged_resources", default=False): bool,
        vol.Optional("wait_for_rollout"): {
            vol.Optional("resource_name", default="foo/bar"): vol.All(str, vol.Match("^.*/.*$")),
            vol.Optional("resource_namespace", default=""): str,
        },
        "azure": {
            vol.Required(
                "kubernetes_naming",
                description=(
                    "Naming convention for the resource."
                    "This should include the {env} parameter. For example"
                    "aks_{env}"
                ),
            ): str
        },
    },
    extra=vol.ALLOW_EXTRA,
)


class DeployToKubernetes(BaseKubernetes):
    """Deploys or updates deployments and services to/on a Kubernetes cluster"""

    def __init__(self, env: ApplicationVersion, config: dict):
        super().__init__(env, config)

        self.vault_name, self.vault_client = KeyVaultClient.vault_and_client(self.config, self.env)
        self.core_v1_api = CoreV1Api()

    def schema(self) -> vol.Schema:
        return DEPLOY_SCHEMA

    def run(self):
        # load some Kubernetes config
        logging.info(f"Deploying to K8S. Environment: {self.env.environment}")

        self.deploy_to_kubernetes(self.config["kubernetes_config_path"], self.application_name)

    def _get_docker_registry_secret(self) -> str:
        """Create a secret containing credentials for logging into the defined docker registry
        """
        docker_credentials = DockerRegistry(self.config, self.env).credentials()
        return b64_encode(
            json.dumps(
                {
                    "auths": {
                        docker_credentials.registry: {
                            "username": docker_credentials.username,
                            "password": docker_credentials.password,
                            "auth": b64_encode(
                                f"{docker_credentials.username}:{docker_credentials.password}"
                            ),
                        }
                    }
                }
            )
        )

    def _render_kubernetes_config(
        self,
        kubernetes_config_path: str,
        application_name: str,
        secrets: Dict[str, str],
        custom_values: Dict[str, str],
    ) -> str:
        kubernetes_config = render_string_with_jinja(
            kubernetes_config_path,
            {
                "docker_tag": self.env.artifact_tag,
                "application_name": application_name,
                "env": self.env.environment,
                "build_env": {**os.environ},
                **secrets,
                **custom_values,
            },
        )
        return kubernetes_config

    def _write_kubernetes_config(self, kubernetes_config: str) -> str:
        rendered_kubernetes_config_path = NamedTemporaryFile(delete=False, mode="w")
        rendered_kubernetes_config_path.write(kubernetes_config)
        rendered_kubernetes_config_path.close()

        return rendered_kubernetes_config_path.name

    def _render_and_write_kubernetes_config(
        self,
        kubernetes_config_path: str,
        application_name: str,
        secrets: List[Secret],
        custom_values: Dict[str, str],
    ) -> str:
        """
        Render the jinja-templated kubernetes configuration adn write it out to a temporary file.
        Args:
            kubernetes_config_path: The raw, jinja-templated kubernetes configuration path.
            application_name: Current application name

        Returns:
            The path to the temporary file where the rendered kubernetes configuration is stored.
        """
        vault_values = {_.jinja_safe_key: ensure_base64(_.val) for _ in secrets}

        context_values = {
            **{
                _.jinja_safe_key: ensure_base64(_.val)
                for _ in Context().get_or_else(ContextKey.EVENTHUB_PRODUCER_POLICY_SECRETS, {})
            },
            **{
                _.jinja_safe_key: ensure_base64(_.val)
                for _ in Context().get_or_else(ContextKey.EVENTHUB_CONSUMER_GROUP_SECRETS, {})
            },
        }

        kubernetes_config = self._render_kubernetes_config(
            kubernetes_config_path, application_name, {**vault_values, **context_values}, custom_values
        )
        return self._write_kubernetes_config(kubernetes_config)

    def _restart_unchanged_resources(self, file_path: str):
        """
        Trigger a restart of all restartable resources.

        Args:
            output: List of output lines that kubectl produced when apply -f was run
        """
        cmd = ["kubectl", "rollout", "restart", "-f", file_path]
        run_shell_command(cmd)
        logger.info("Restarted all possible resources")

    def _await_rollout(self, target: str, target_namespace: str):
        """Await the rollout of a specified target to complete

        This function awaits the completion of the rollout of the target in the target_namespace. If it
        fails, or if it does not complete successfully within the default kubectl timeout, a
        ChildProcessorError is thrown.

        NOTE: This may be a bit 'racy', in the sense that if multiple CI pipelines are running simultaneously,
        the await may not always be correct (it may await a different revision than the one that this step had
        just deployed).

        Args:
            target: The resource to target. This resource should be named according to the
                    <resource_type>/name convention.
            target_namespace: The namespace of the resource

        Raises:
            ChildProcessError: if the rollout of the specified resource did not complete successfully.
        """
        cmd = ["kubectl", "rollout", "--namespace", target_namespace, "status", target, "--watch=True"]
        exit_code, _ = run_shell_command(cmd)
        if exit_code != 0:
            raise ChildProcessError(
                f"Specified deployment {target} in namespace {target_namespace} "
                "did not successfully rollout."
            )
        logger.info("Rollout successful")

    def _apply_kubernetes_config_file(self, file_path: str):
        """
        Create/Update the kubernetes resources based on the provided file_path to the configuration. This
        function assumes that the file does NOT contain any Jinja-templated variables anymore (i.e. it's
        been rendered)

        Args:
            file_path: Path to the kubernetes configuration
        """
        # workaround for some CI runners that override the default k8s namespace
        cmd = ["kubectl", "config", "set-context", self.cluster_name, "--namespace", "default"]
        exit_code, _ = run_shell_command(cmd)
        if exit_code != 0:
            raise ChildProcessError(f"Couldn't set-context for cluster {self.cluster_name}")

        cmd = ["kubectl", "apply", "-f", file_path]
        exit_code, response = run_shell_command(cmd)
        if exit_code != 0:
            raise ChildProcessError(f"Couldn't apply Kubernetes config from path {file_path}")

    def _create_image_pull_secret(self, application_name: str) -> str:
        pull_secrets_yaml = os.path.join(
            os.path.dirname(os.path.abspath(__file__)), "assets", "kubernetes_image_pull_secrets.yml.j2"
        )
        return self._render_and_write_kubernetes_config(
            kubernetes_config_path=pull_secrets_yaml,
            application_name=application_name,
            secrets=[Secret("pull_secret", self._get_docker_registry_secret())],
            custom_values={
                "namespace": Secret("namespace", self.config["image_pull_secret"]["namespace"]).val,
                "secret_name": Secret("secret_name", self.config["image_pull_secret"]["secret_name"]).val,
            },
        )

    def _get_custom_values(self) -> Dict[str, str]:
        if self.config["custom_values"]:
            if self.env.environment in self.config["custom_values"]:
                return self.config["custom_values"][self.env.environment]
            else:
                raise ValueError(
                    "No matching environment was found for custom values. Check your Takeoff config"
                    f"and your environment names. Looking for environment: {self.env.environment}"
                )
        return {}

    def deploy_to_kubernetes(self, kubernetes_config_path: str, application_name: str):
        """Run a full deployment to Kubernetes, given configuration.

        Args:
            kubernetes_config_path: path to the jinja-templated kubernetes config
            application_name: current application name
        """
        self._authenticate_with_kubernetes()

        # load the kubeconfig we just fetched
        kubernetes.config.load_kube_config()
        logger.info("Kubeconfig loaded")

        if self.config["image_pull_secret"]["create"]:
            file_path = self._create_image_pull_secret(application_name)
            self._apply_kubernetes_config_file(file_path)
            logger.info("Docker registry secret available")

        secrets = KeyVaultCredentialsMixin(self.vault_name, self.vault_client).get_keyvault_secrets(
            self.application_name
        )

        custom_values = self._get_custom_values()

        rendered_kubernetes_config_path = self._render_and_write_kubernetes_config(
            kubernetes_config_path, application_name, secrets, custom_values
        )
        logger.info("Kubernetes config rendered")

        self._apply_kubernetes_config_file(rendered_kubernetes_config_path)
        logger.info("Applied rendered Kubernetes config")

        if self.config["restart_unchanged_resources"]:
            self._restart_unchanged_resources(rendered_kubernetes_config_path)

        if "wait_for_rollout" in self.config.keys():
            self._await_rollout(
                self.config["wait_for_rollout"]["resource_name"],
                self.config["wait_for_rollout"]["resource_namespace"],
            )

    @property
    def kubernetes_namespace(self):
        return self.application_name

    @property
    def cluster_name(self):
        return get_kubernetes_name(self.config, self.env)