import datetime
import json
import time
from collections import OrderedDict
from copy import deepcopy

from click import prompt, secho

from indico_install.config import REMOTE_TEMPLATES_PATH, merge_dicts, yaml
from indico_install.utils import (
    current_user,
    diff_dicts,
    get_deployed_frontend,
    get_deployed_images,
    load_from_configmap,
    run_cmd,
    string_to_tag,
)

TRACKER_CONFIGMAP = "cluster-manager"
TRACKER_CONFIGMAP_BACKUP = "cluster-manager-backup"
LOCK_TIMEOUT = 60  # seconds to wait for configmap lock


"""
Updraft Library
"""


def get_services_yaml_from_tag(updraft_tag, apps_only=True):
    remote_services_yaml = REMOTE_TEMPLATES_PATH + updraft_tag + "/services.yaml"
    out = run_cmd(f"wget -qO- {remote_services_yaml}", silent=True)
    services = yaml.safe_load(out)
    services["services"] = {
        s: v
        for s, v in services["services"].items()
        if v.get("group", None) not in ("static", "configs") and "nginx" not in s
    }
    return services


def get_services_from_tag(updraft_tag):
    services = get_services_yaml_from_tag(updraft_tag, apps_only=True)
    results = {}
    for app in sorted(services["services"]):
        app_img = services["images"].get(app.replace("-", "")) or next(
            (
                services["images"][i]
                for i in services["images"].keys()
                if app.split("-")[0] in i
            ),
            None,
        )
        if app_img:
            results[app] = app_img
    results["stratosphere"] = services["frontend"]["hash"]
    return results


class ClusterManager(object):
    """
    TODO: make this hold state of the actual input file
    so we have documentation on what this looks like
    Probably with versions!
    """

    information_keys = ("indico_version", "cluster_config")

    def __init__(self, reconcile=False, input_yaml=None):
        """
        Initializes a version cluster_manager for the current cluster
        and preloads with existing data
        """
        self.indico_version = None
        self._lock = None
        self.cm_exists = False
        self.cluster_config = OrderedDict({})

        self.load_from_cluster()
        if not self.cm_exists:
            if not input_yaml:
                secho(
                    f"Input (cluster) yaml not provided to initialize Cluster Manager",
                    fg="yellow",
                )
                return
            with open(input_yaml, "r") as f:
                self.cluster_config = yaml.safe_load(f)
                self.indico_version = string_to_tag(prompt("Last known release tag"))
        elif input_yaml:
            secho(f"Ignoring input yaml. Manager exists", fg="yellow")

        if reconcile:
            self.reconcile()

    @property
    def current_state(self):
        state = {"stratosphere": self.cluster_config["frontend"].get("hash")}
        for svc, val in self.cluster_config["services"].items():
            img = val.get("values", {}).get("image")
            if not img or "nginx" in svc or "nginx" in img:
                continue
            state[svc] = img
        return state

    @property
    def registry(self):
        return self.cluster_config.get("dockerRegistry", "gcr.io/new-indico") + "/"

    @classmethod
    def diff(cls, version_1, version_2):
        old_service_hashes = get_services_from_tag(version_1)
        new_service_hashes = get_services_from_tag(version_2)
        return diff_dicts(old_service_hashes, new_service_hashes)

    def clean_services(self):
        updates = {}
        if "services" in self.cluster_config:
            services = deepcopy(self.cluster_config["services"])
            for svc, val in self.cluster_config["services"].items():
                if "image" not in val.get("values", {}):
                    continue
                del val["values"]["image"]
                val["values"].pop("updated_at", None)
                val["values"].pop("updated_by", None)
                # we don't want empty values floating around either
                if not val["values"]:
                    del val["values"]
            updates["services"] = services
        if "hash" in self.cluster_config["frontend"]:
            updates["frontend"] = {"hash": self.cluster_config["frontend"].pop("hash")}
        return updates

    def load_from_cluster(self, backup=False):
        """
        Use the version configmap on the cluster to populate this Tracker instance
        """
        conf = TRACKER_CONFIGMAP_BACKUP if backup else TRACKER_CONFIGMAP
        known_info = load_from_configmap(conf, data_parser=json.loads)
        if known_info:
            known_info = known_info["data"]
            self.cm_exists = True
            self.indico_version = known_info.get("indico_version")
            self.cluster_config = OrderedDict(
                known_info.get("cluster_config", self.cluster_config)
            )
        return known_info is not None

    def edit_cluster_config(self, changes=None, version=None):
        """
        Take a patch for cluster config and apply
        """
        if changes:
            self.cluster_config = merge_dicts(self.cluster_config, changes)
        if version:
            self.indico_version = version

    def _already_locked(self):
        """
        Determine if configmap is locked by someone other than us
        Return:
            - None if no lock
            - 0 if the lock is owned by us
            - True if a lock exists, is not owned by us, and is still valid
        """
        out = run_cmd(
            f"kubectl get cm {TRACKER_CONFIGMAP} --show-labels --no-headers 2>&1"
            "| awk '{print $4}'",
            silent=True,
        )
        if "NotFound" in out:
            return None
        lock_label = next((i for i in out.split(",") if i.startswith("lock")), None)
        if not lock_label:
            return

        lock = float(lock_label.split("=", 1)[1])
        if lock == self._lock:
            return 0
        return time.time() - lock < LOCK_TIMEOUT

    def lock(self, retries=15):
        """
        Locks configmap for LOCK_TIMEOUT seconds
        Raises TimeoutError if existing lock is not released during retries
        No op if manager does not exist
        """
        if not self.cm_exists:
            return
        curr_lock = self._already_locked()
        if curr_lock == 0:
            return self._lock

        needed_to_wait = curr_lock
        while curr_lock and retries:
            retries -= 1
            secho("Waiting for lock...", fg="yellow")
            time.sleep(2)
            curr_lock = self._already_locked()

        if curr_lock:
            raise TimeoutError("Lock was not acquired. Please try again later")

        self._lock = time.time()
        run_cmd(f"kubectl label cm {TRACKER_CONFIGMAP} lock={self._lock}", silent=True)
        if needed_to_wait:
            self.load_from_cluster()
        return self._lock

    def unlock(self):
        """
        Unlocks config manager only if the lock is owned by us
        """
        if not self.cm_exists:
            return
        curr_lock = self._already_locked()
        if curr_lock != 0:
            return
        run_cmd(f"kubectl label cm {TRACKER_CONFIGMAP} lock-", silent=True)

    def reconcile(self):
        """
        If there is a difference between what's on the cluster
        and the ClusterManager, update self
        """
        info = get_deployed_images()

        self.edit_cluster_config(
            changes={
                "frontend": {"hash": get_deployed_frontend()},
                "services": {
                    k: {"values": {"image": v.replace(self.registry, "")}}
                    for k, v in info.items()
                },
            }
        )

    def update_from_current(self, services):
        """
        Similar to update, but we pull the images of the services
        directly from the cluster. Unlike reconciling, we associate
        the changes with the current user and time
        """
        info = {k: v for k, v in get_deployed_images().items() if k in services}
        updated_fe = (
            get_deployed_frontend()
            if next((s for s in services if "app-edge-nginx" in s), None)
            else None
        )
        self.update(state=info, frontend=updated_fe)

    def update(self, state: dict = None, frontend: str = None, save=True):
        """
        Version is some release tag
        state is a dictionary of {svc: image}
        """
        if not state and not frontend:
            return
        changes = {}
        curr_user = current_user()
        curr_time = str(datetime.datetime.now())
        if state:
            changes["services"] = OrderedDict(
                {
                    k: {"values": {
                        "image": v.replace(self.registry, ""),
                        "updated_by": curr_user,
                        "updated_at": curr_time,
                    }}
                    for k, v in sorted(state.items())
                }
            )
        if frontend:
            changes["frontend"] = {
                "hash": frontend,
                "updated_by": curr_user,
                "updated_at": curr_time,
            }

        self.lock()
        try:
            self.edit_cluster_config(changes)

            if save:
                self.save()
        finally:
            self.unlock()

    def to_dict(self):
        return OrderedDict({k: getattr(self, k) for k in self.information_keys})

    def to_configmap(self, backup=False):
        return {
            "apiVersion": "v1",
            "kind": "ConfigMap",
            "metadata": {
                "name": TRACKER_CONFIGMAP_BACKUP if backup else TRACKER_CONFIGMAP,
            },
            "data": OrderedDict({k: json.dumps(v) for k, v in self.to_dict().items()}),
        }

    def to_str(self):
        return yaml.dump(self.to_dict(), default_flow_style=False)

    def save(self, backup=False):
        """
        Update configmap on cluster with self
        We use json instead of YAML to avoid issues with boolean encoding
        """
        run_cmd(
            f"echo '{json.dumps(self.to_configmap(backup=backup))}' | kubectl apply -f -",
            silent=True,
        )

    def diff_version(self, tag=None):
        tag = tag or self.indico_version
        if not tag or tag.startswith("GIT: "):
            return
        old_service_hashes = get_services_from_tag(tag)
        return diff_dicts(old_service_hashes, self.current_state)
