# Copyright Least Authority Enterprises.
# See LICENSE for details.

"""
View implementation.

Theory of Operation
===================

#. Combine a data source with a text-emitting renderer function and a text
   output-capable target (e.g. a file descriptor).
"""

from __future__ import unicode_literals, division

from struct import pack, unpack
from termios import TIOCGWINSZ
from fcntl import ioctl

from twisted.internet.defer import gatherResults
from twisted.python.compat import unicode

from datetime import datetime

from bitmath import Byte

import attr
from attr import validators

COLUMNS = [
    (20, "POD"),
    (26, "(CONTAINER)"),
    (12, "%CPU"),
    (12, "MEM"),
    (7, "%MEM")
]


def kubetop(reactor, datasource, datasink):
    return gatherResults([
        datasource.nodes(), datasource.pods(),
    ]).addCallback(_render_kubetop, datasink, reactor)



@attr.s
class Size(object):
    rows = attr.ib()
    columns = attr.ib()
    xpixels = attr.ib()
    ypixels = attr.ib()



def terminal_size(terminal_fd):
    s = pack('HHHH', 0, 0, 0, 0)
    t = ioctl(terminal_fd, TIOCGWINSZ, s)
    return Size(*unpack('HHHH', t))



@attr.s
class Terminal(object):
    fd = attr.ib()

    def size(self):
        return terminal_size(self.fd)



@attr.s
class Sink(object):
    terminal = attr.ib()
    outfile = attr.ib()


    @classmethod
    def from_file(cls, outfile):
        return cls(Terminal(outfile.fileno()), outfile)


    def write(self, text):
        size = self.terminal.size()
        num_lines = size.rows
        truncated = "\n".join(text.splitlines()[:num_lines])
        self.outfile.write(truncated)
        self.outfile.flush()



def _render_kubetop(data, sink, reactor):
    sink.write(_render_pod_top(reactor, data))


def _render_row(*values):
    fields = []
    debt = 0
    for value, (width, label) in zip(values, COLUMNS):
        field = "{}".format(value).rjust(width - max(0, debt))
        debt = len(field) - width
        fields.append(field)
    return "".join(fields) + "\n"


def _clear():
    return "\x1b[2J\x1b[1;1H"


def _render_clockline(reactor):
    return "kubetop - {}\n".format(
        datetime.fromtimestamp(reactor.seconds()).strftime("%H:%M:%S")
    )


def _render_pod_top(reactor, data):
    (node_info, pod_info) = data
    nodes = node_info["info"]["items"]
    node_usage = node_info["usage"]["items"]

    pods = pod_info["info"].items
    pod_usage = pod_info["usage"]["items"]

    return "".join((
        _clear(),
        _render_clockline(reactor),
        _render_nodes(nodes, node_usage, pods),
        _render_pod_phase_counts(pods),
        _render_header(nodes, pods),
        _render_pods(pods, pod_usage, nodes),
    ))


def _render_pod_phase_counts(pods):
    phases = {}
    for pod in pods:
        phases[pod.status.phase] = phases.get(pod.status.phase, 0) + 1

    return (
        "Pods: "
        "{total:>8} total "
        "{running:>8} running "
        "{terminating:>8} terminating "
        "{pending:>8} pending\n"
    ).format(
        total=len(pods),
        running=phases.get("running", 0),
        terminating=phases.get("terminating", 0),
        pending=phases.get("pending", 0),
    )


def _render_header(nodes, pods):
    return _render_row(*(
        label
        for (width, label)
        in COLUMNS
    ))


def _render_nodes(nodes, node_usage, pods):
    usage_by_name = {
        usage["metadata"]["name"]: usage
        for usage
        in node_usage
    }
    def pods_for_node(node):
        return list(
            pod
            for pod
            in pods
            if _pod_on_node(pod, node)
        )

    return "".join(
        "Node {} {}\n".format(
            i,
            _render_node(
                node,
                usage_by_name[node["metadata"]["name"]],
                pods_for_node(node),
            ),
        )
        for i, node
        in enumerate(nodes)
    )


def _render_node(node, usage, pods):
    # From v1.NodeStatus model documentation:
    #
    #     Allocatable represents the resources of a node that are available
    #     for scheduling. Defaults to Capacity.
    allocatable = node["status"]["allocatable"]

    cpu_max = parse_cpu(allocatable["cpu"])
    cpu_used = parse_cpu(usage["usage"]["cpu"])

    mem_max = parse_memory(allocatable["memory"])
    mem_used = parse_memory(usage["usage"]["memory"])

    pod_count = len(pods)
    pod_max = int(allocatable["pods"])

    if any(
            condition["type"] == "Ready" and condition["status"] == "True"
            for condition
            in node["status"]["conditions"]
    ):
        condition = "Ready"
    else:
        condition = "NotReady"

    return (
        "CPU% {cpu:>6.2f} "
        "MEM% {mem} ({mem_used}/{mem_max})  "
        "POD% {pod:>5.2f} ({pod_count:3}/{pod_max:3}) "
        "{condition}"
    ).format(
        cpu=cpu_used / cpu_max * 100,
        mem=mem_max.render_percentage(mem_used),
        mem_used=mem_used.render("4.0"),
        mem_max=mem_max.render("4.0"),
        pod=pod_count / pod_max * 100,
        pod_count=pod_count,
        pod_max=pod_max,
        condition=condition,
    )



def _pod_on_node(pod, node):
    return pod.status is not None and pod.status.hostIP in (
        addr["address"] for addr in node["status"]["addresses"]
    )



class _UnknownMemory(object):
    def render(self):
        return "???"


    def render_percentage(self, portion):
        return ""



@attr.s(frozen=True)
class _Memory(object):
    amount = attr.ib(validator=validators.instance_of(Byte))

    def render(self, fmt):
        amount = self.amount.best_prefix()
        return ("{:" + fmt + "f} {}").format(float(amount), amount.unit_singular)


    def render_percentage(self, portion):
        return "{:>5.2f}".format(portion.amount / self.amount * 100)



@attr.s(frozen=True)
class _CPU(object):
    # in millicpus
    amount = attr.ib(validator=validators.instance_of(int))

    def render_percentage(self, portion):
        return "{:>5.1f}".format(portion.amount / self.amount * 100)


def _node_allocable_memory(pod, nodes):
    for node in nodes:
        if _pod_on_node(pod, node):
            return parse_memory(node["status"]["allocatable"]["memory"])
    return _UnknownMemory()


def _render_pods(pods, pod_usage, nodes):
    pod_by_name = {
        pod.metadata.name: pod
        for pod
        in pods
    }
    pod_data = (
        (
            _render_pod(
                usage,
                _node_allocable_memory(
                    pod_by_name[usage["metadata"]["name"]],
                    nodes,
                ),
            ),
            _render_containers(usage["containers"]),
        )
        for usage
        in sorted(pod_usage, key=_pod_stats, reverse=True)
    )
    return "".join(
        rendered_pod + rendered_containers
        for (rendered_pod, rendered_containers)
        in pod_data
    )


def _pod_stats(pod):
    cpu = sum(
        map(
            parse_cpu, (
                container["usage"]["cpu"]
                for container
                in pod["containers"]
            ),
        ), 0,
    )
    mem = sum(
        map(
            lambda s: parse_memory(s).amount, (
                container["usage"]["memory"]
                for container
                in pod["containers"]
            ),
        ), Byte(0),
    )
    return (_CPU(cpu), _Memory(mem))


def _render_limited_width(s, w):
    if w < 3:
        raise ValueError("Minimum rendering width is 3")
    if len(s) <= w:
        return s
    return (
        s[:int(round(w / 2 - 1))] +
        "\N{HORIZONTAL ELLIPSIS}" +
        s[-int(w / 2):]
    )


def _render_pod(pod, node_allocable_memory):
    cpu, mem = _pod_stats(pod)
    mem_percent = node_allocable_memory.render_percentage(mem)
    return _render_row(
        # Limit rendered name to combined width of the pod and container
        # columns.
        _render_limited_width(pod["metadata"]["name"], 46),
        "",
        _CPU(1000).render_percentage(cpu),
        mem.render("8.2"),
        mem_percent,
    )


def _render_containers(containers):
    return "".join((
        _render_container(container)
        for container
        in sorted(containers, key=lambda c: -parse_cpu(c["usage"]["cpu"]))
    ))


def _render_container(container):
    return _render_row(
        "",
        _render_limited_width("(" + container["name"] + ")", 46),
        _CPU(1000).render_percentage(_CPU(parse_cpu(container["usage"]["cpu"]))),
        parse_memory(container["usage"]["memory"]).render("8.2"),
        "",
    )


def partition(seq, pred):
    return (
        u"".join(x for x in seq if pred(x)),
        u"".join(x for x in seq if not pred(x)),
    )


def parse_cpu(s):
    return parse_k8s_resource(s, default_scale=1000)


def parse_memory(s):
    return _Memory(Byte(parse_k8s_resource(s, default_scale=1)))


def parse_k8s_resource(s, default_scale):
    amount, suffix = partition(s, unicode.isdigit)
    try:
        scale = suffix_scale(suffix)
    except KeyError:
        scale = default_scale
    return int(amount) * scale


def suffix_scale(suffix):
    return {
        "m": 1,
        "K": 2 ** 10,
        "Ki": 2 ** 10,
        "M": 2 ** 20,
        "Mi": 2 ** 20,
        "G": 2 ** 30,
        "Gi": 2 ** 30,
        "T": 2 ** 40,
        "Ti": 2 ** 40,
        "P": 2 ** 50,
        "Pi": 2 ** 50,
        "E": 2 ** 60,
        "Ei": 2 ** 60,
    }[suffix]