#!/usr/bin/env python3
# Copyright (C) 2020 Sipwise GmbH, Austria
#
# This program 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.
#
# This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.
import re
from configparser import RawConfigParser
from xmlrpc.client import Fault
from xmlrpc.client import ServerProxy

import click
import redis
from yaml import load

try:
    from yaml import CLoader as Loader
except ImportError:
    from yaml import Loader

CONFIG = "/etc/ngcp-system-tools/ngcp-debug-subscriber.conf"
NETWORK_YML = "/etc/ngcp-config/network.yml"
SUB_FMT = r"^[^@]+@[^@]+$"
SUB_RE = re.compile(SUB_FMT)
LB_FMT = r"^([^:]+):\d+$"
LB_RE = re.compile(LB_FMT)
REDIS_KEY = "debug_uri:entry::{}"

cfg = RawConfigParser()


def check_lb(ctx, param, value):
    ctx.obj["LB_value"] = value
    if ctx.obj["RESOLVE_LB"]:
        res = ctx.obj["NETWORK"].resolve_lb(value)
    else:
        res = value
    if LB_RE.match(res):
        return res
    msg = "'{}' doesn't have format:'{}'"
    raise click.BadParameter(msg.format(value, LB_FMT))


def check_subs(ctx, param, value):
    msg = "'{}' doesn't have format:'{}'"
    for s in value:
        if not SUB_RE.match(s):
            raise click.BadParameter(msg.format(s, SUB_FMT))
    return value


def check_uri(ctx, param, value):
    return ctx.obj["NETWORK"].resolve_prx(value)


def check_fault(err):
    if err.faultString == "No such htable":
        msg = "htable not there, "
        msg += "kamailio.lb.debug_uri.enable config should be 'yes'"
        return msg
    else:
        return err.faultString


class Network:
    def __init__(self, yaml, debug=False):
        self.dbg = debug
        self.lb_port = cfg.getint("kamailio", "lb_port")
        self.proxy_port = cfg.getint("kamailio", "proxy_port")
        try:
            with click.open_file(yaml, "r") as f:
                self.data = load(f, Loader=Loader)
        except IOError:
            raise SystemExit(
                "Error: could not read network file '%s'" % (yaml)
            )

    def _resolve(self, _type, val):
        h_list = self.get_host_by_role(_type)
        if val not in h_list:
            if _type in ["lb", "redis"]:
                role = "lb"
            else:
                role = _type
            msg = "{} is not a host defined at network.yml as role {}:{}"
            raise click.BadParameter(msg.format(val, role, h_list))
        if _type == "lb":
            fmt = "{}:%d" % (self.lb_port)
        elif _type == "redis":
            fmt = "{}"
        else:
            fmt = "sip:{}:%d" % (self.proxy_port)
        res = fmt.format(h_list[val])
        click.echo("{}[{}] resolved to:{}".format(val, _type, res))
        return res

    def resolve_lb(self, lb):
        return self._resolve("lb", lb)

    def resolve_prx(self, uri):
        return self._resolve("proxy", uri)

    def resolve_redis(self, host):
        return self._resolve("redis", host)

    def get_shared_ip(self, hostname, _type):
        host = self.data["hosts"].get(hostname)
        if host is None:
            raise click.UsageError(
                "hostname:{} not in network.yml".format(hostname)
            )
        for i in host["interfaces"]:
            if _type in host[i]["type"]:
                return host[i]["shared_ip"][0]

    def get_host_by_role(self, role):
        res = {}
        if role == "redis":
            role = "lb"
            int_type = "ha_int"
        else:
            int_type = "sip_int"
        for k, v in self.data["hosts"].items():
            if role in v["role"]:
                shared_name = k[:-1]
                res[shared_name] = self.get_shared_ip(k, int_type)
        return res

    def show_nodes(self):
        lb = self.get_host_by_role("lb")
        prx = self.get_host_by_role("proxy")
        click.echo("lb nodes:")
        for k, v in lb.items():
            click.echo(" - {} => {}:{}".format(k, v, self.lb_port))
        click.echo("proxy nodes:")
        for k, v in prx.items():
            click.echo(" - {} => {}:{}".format(k, v, self.proxy_port))


class Client:
    def __init__(self, lb, redis_host, dry_run=False, debug=False):
        lb_uri = "http://{}/".format(lb)
        self.lb, self.lb_port = lb.split(":")
        self.redis_host = redis_host
        self.table = "dbguri"
        self.dry_run = dry_run
        self.debug = debug
        self._info = []
        if dry_run:
            msg = "Client: would connect to {}"
            click.echo(msg.format(lb_uri))
        else:
            self.proxy = ServerProxy(lb_uri, verbose=debug)

    def _store_info(self, _type, sub, server_uri=None):
        if _type == "add":
            val = {
                "redis_cmd": "hmset",
                "redis_key": REDIS_KEY.format(sub),
                "key_name": sub,
                "key_type": 0,
                "value_type": 0,
                "key_value": server_uri,
            }
        elif _type == "delete":
            # DEL: ‘del’ is a reserved keyword in the Python syntax.
            # Therefore redis-py uses ‘delete’ instead.
            val = {"redis_cmd": "delete", "redis_key": REDIS_KEY.format(sub)}
        elif _type == "flush":
            val = {"redis_cmd": "flushdb", "redis_key": None}
        self._info.append(val)

    def store(self):
        redis_params = {
            "host": self.redis_host,
            "port": cfg.getint("redis", "port"),
            "db": cfg.getint("redis", "db"),
        }
        if self.dry_run:
            msg = "Client: would send to redis({})"
            click.echo(msg.format(redis_params))
            for i in self._info:
                click.echo("{}".format(i))
            return
        r = redis.Redis(**redis_params)

        for i in self._info:
            redis_cmd = i.pop("redis_cmd")
            redis_key = i.pop("redis_key")
            cmd = getattr(r, redis_cmd)
            if len(i) > 0:
                res = cmd(redis_key, i)
            else:
                res = cmd(redis_key)
            if self.debug:
                click.echo(
                    "redis({}) cmd:{} {} {} response:{}".format(
                        redis_params, redis_cmd, redis_key, i, res
                    )
                )

    def list(self):
        if self.dry_run:
            msg = "Client: would send dump({})"
            click.echo(msg.format(self.table))
            return

        res = self.proxy.htable.dump(self.table)
        msg = "{} => {}"
        click.echo("====")
        res_l = {}
        for r in res:
            for slot in r["slot"]:
                res_l[slot["name"]] = slot["value"]
        for k, v in sorted(res_l.items()):
            click.echo(msg.format(k, v))
        click.echo("====")

    def add(self, subs, server_uri):
        if self.dry_run:
            msg = "Client: would send htable.sets({}, {}, {})"
            for sub in subs:
                click.echo(msg.format(self.table, sub, server_uri))
            return

        for sub in subs:
            self._store_info("add", sub, server_uri)
            self.proxy.htable.sets(self.table, sub, server_uri)

    def delete(self, subs):
        if self.dry_run:
            msg = "Client: would send htable.delete({}, {})"
            for sub in subs:
                click.echo(msg.format(self.table, sub))
            return

        for sub in subs:
            self._store_info("delete", sub)
            self.proxy.htable.delete(self.table, sub)

    def flush(self):
        if self.dry_run:
            msg = "Client: would send htable.flush({})"
            click.echo(msg.format(self.table))
            return
        self._store_info("flush", None)
        self.proxy.htable.flush(self.table)


@click.group()
@click.option("--dry-run", is_flag=True, default=False)
@click.option("--debug/--no-debug", default=False)
@click.option(
    "--config",
    type=click.Path(),
    default=CONFIG,
    help="config path, default:{}".format(CONFIG),
)
@click.option(
    "--network",
    type=click.Path(),
    default=NETWORK_YML,
    help="network.yml path, default:{}".format(NETWORK_YML),
)
@click.option(
    "--resolve-lb/--no-resolve-lb",
    default=True,
    help="resolve LB shared_name to IP:PORT, default: --resolve-lb",
)
@click.pass_context
def cli(ctx, dry_run, debug, config, network, resolve_lb):
    """
    Manage subscribers to debug

    examples of use:

    \b
    ngcp-debug-subscriber add lb01 4311001@dom.ok test@dom.ok prx02
    ngcp-debug-subscriber delete lb01 4311001@dom.ok test@dom.ok
    ngcp-debug-subscriber list lb01
    """
    ctx.ensure_object(dict)
    ctx.obj["DEBUG"] = debug
    ctx.obj["DRY"] = dry_run
    ctx.obj["RESOLVE_LB"] = resolve_lb
    cfg.read_file(open(config))
    if debug:
        click.echo("config:{} with sections:{}".format(config, cfg.sections()))
    if cfg.get("DEFAULT", "enable") != "yes":
        click.echo(
            "WARNING: kamailio.lb.debug_uri.enable config must be 'yes'"
        )
    ctx.obj["NETWORK"] = Network(network, debug=debug)


@cli.command()
@click.argument("lb", callback=check_lb)
@click.argument("subscriber", callback=check_subs, nargs=-1, required=True)
@click.argument("uri", callback=check_uri)
@click.option(
    "--store/--no-store",
    default=True,
    help="store values in REDIS, default is --store",
)
@click.option("--redis-host", help="REDIS hostname, default LB")
@click.pass_context
def add(ctx, lb, subscriber, uri, store, redis_host):
    """
    Add SUBSCRIBER to debug_uri htable
    Sends messages To/From SUBSCRIBER to URI

    LB kamailio node to connect. Use shared_name like lb01

    SUBSCRIBER example: testusr@domain.ko

    URI kamailio inactive proxy node. Use shared_name like prx02
    """
    if redis_host is None:
        if ctx.obj["RESOLVE_LB"]:
            redis_host = ctx.obj["NETWORK"].resolve_redis(ctx.obj["LB_value"])
        else:
            redis_host = ctx.obj["LB_value"]
    try:
        cli = Client(
            lb, redis_host, dry_run=ctx.obj["DRY"], debug=ctx.obj["DEBUG"]
        )
        cli.add(subscriber, uri)
        if store:
            cli.store()
    except Fault as err:
        raise click.UsageError(check_fault(err), ctx)


@cli.command()
@click.argument("lb", callback=check_lb)
@click.argument("subscriber", callback=check_subs, nargs=-1, required=True)
@click.option(
    "--store/--no-store",
    default=True,
    help="store values in REDIS, default is --store",
)
@click.option("--redis-host", help="REDIS hostname, default LB")
@click.pass_context
def delete(ctx, lb, subscriber, store, redis_host):
    """
    Remove SUBSCRIBER from debug_uri htable

    LB kamailio node to connect. Use shared_name like lb01

    SUBSCRIBER example: testusr@domain.ko
    """
    if redis_host is None:
        if ctx.obj["RESOLVE_LB"]:
            redis_host = ctx.obj["NETWORK"].resolve_redis(ctx.obj["LB_value"])
        else:
            redis_host = ctx.obj["LB_value"]
    try:
        cli = Client(
            lb, redis_host, dry_run=ctx.obj["DRY"], debug=ctx.obj["DEBUG"]
        )
        cli.delete(subscriber)
        if store:
            cli.store()
    except Fault as err:
        raise click.UsageError(check_fault(err), ctx)


@cli.command()
@click.argument("lb", callback=check_lb)
@click.option(
    "--store/--no-store",
    default=True,
    help="store values in REDIS, default is --store",
)
@click.option("--redis-host", help="REDIS hostname, default LB")
@click.pass_context
def flush(ctx, lb, store, redis_host):
    """
    flush debug_uri htable

    LB kamailio node to connect. Use shared_name like lb01
    """
    if redis_host is None:
        if ctx.obj["RESOLVE_LB"]:
            redis_host = ctx.obj["NETWORK"].resolve_redis(ctx.obj["LB_value"])
        else:
            redis_host = ctx.obj["LB_value"]
    try:
        cli = Client(
            lb, redis_host, dry_run=ctx.obj["DRY"], debug=ctx.obj["DEBUG"]
        )
        cli.flush()
        if store:
            cli.store()
    except Fault as err:
        raise click.UsageError(check_fault(err), ctx)


@cli.command()
@click.argument("lb", callback=check_lb)
@click.pass_context
def list(ctx, lb):
    """
    Prints debug_uri htable info

    LB kamailio node to connect. Use shared_name like lb01
    """
    try:
        cli = Client(lb, None, dry_run=ctx.obj["DRY"], debug=ctx.obj["DEBUG"])
        cli.list()
    except Fault as err:
        raise click.UsageError(check_fault(err), ctx)


@cli.command()
@click.pass_context
def show(ctx):
    """
    Prints valid values taken from NETWORK_YML
    """
    ctx.obj["NETWORK"].show_nodes()


if __name__ == "__main__":
    cli(obj={})
