#!/usr/bin/python3
from __future__ import annotations

import getopt
import json
import os.path
import re
import subprocess
import sys
from enum import Enum
from os import R_OK
from os import W_OK
from os import X_OK
from re import Pattern


class KeyType(Enum):
    STRING = "string"
    LIST = "list"
    SET = "set"
    ZSET = "zset"
    HASH = "hash"
    NONE = "none"


HOST = ""
DB = ""
PAIR = ""
KEYDB_CMD_DB = ""
KEYDB_CMD_PAIR = ""
EXTENDED = False
ARGS = [
    "clearpeer",
    "clearuser",
    "dump",
    "dumps",
    "load",
    "find",
    "type",
    "get",
    "get_list",
    "get_set",
    "get_hash",
    "del",
    "set",
    "flushdb",
    "getpeer",
    "peer_callids",
    "getuser",
    "user_callids",
    "help",
    "scan",
]
RE_HOST = re.compile(r"^(spce|localhost|127\.0\.0\.\d+)$")
RE_ARGS = re.compile(r"'[^']*'|\S+")
JSON_ENCODER_OPTIONS = {
    "separators": (",", ":"),
    "indent": 2,
    "sort_keys": True,
}

DOCS = """ ngcp-redis-helper [OPTIONS] COMMAND [<text>]

      COMMANDS:
        clearpeer <peer>     - Clear all records for peer <peer>
        clearuser <user>     - Clear all records for user <user>
        dumps                - Dump all Keydb records as JSON
        dump <path>          - Dump all Keydb records as JSON to <path>
        load <path>          - Loads all Keydb records from JSON file in <path>
        find <text>           - Find all records matching <text>
        type <key>           - Type <key>
        get <key>            - Get <key> value
        get_hash <key>       - Get <key> values from hash
        get_list <key>       - Get <key> values from list
        get_set <key>        - Get <key> values from set
        del <key>            - Delete key <key>
        set <key> <value>    - Set key <key> value <value>
        flushdb               - Delete *ALL* keys
        getpeer <peer>       - Get peer information for peer <peer>
        peer_callids <id>    - Get all callids for peer <id>
        getuser <user>       - Get useful information about user <user>
        user_callids <user>  - Get all callids for user <user>
        help                 - Show this help and exit
        scan <text>          - Scan the selected database on <text> (use * and ? for patterns)
     OPTIONS:
        -h <host>            - Database host. If not specified, defaults to local keydb configuration
        -n <keydb_db>        - Database number. If not specified, defaults to local keydb configuration
        -e                   - To be used with the commands 'dump' and 'dumps'. It additionally dumps the content of each single key.
      """  # noqa: E501


def exit_with_error(*error_messages: str) -> None:
    for error_message in error_messages:
        print(error_message)
    sys.exit(2)


def exit_gracefully(*messages: str) -> None:
    if len(messages) > 0:
        for message in messages:
            if message:
                print(message)
    sys.exit(0)


def run_process(cmd: str) -> str:
    try:
        out = subprocess.run(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            shell=True,
        )
        if "ERR" in out.stdout:
            out.stderr = out.stdout
        if out.stderr:
            raise subprocess.CalledProcessError(
                returncode=0,
                cmd=out.args,
                output=out.stdout.strip(),
                stderr=out.stderr.strip(),
            )
        return out.stdout.strip()
    except subprocess.CalledProcessError as error:
        exit_with_error(error.stderr)


def is_regex_match(string: str, pattern: Pattern[str]) -> bool:
    return True if re.search(pattern, string) else False


def check_file(file: str, privilege: str = "") -> bool:
    match privilege:
        case "r":
            return os.path.isfile(file) and os.access(file, R_OK)
        case "w":
            return os.path.isfile(file) and os.access(file, W_OK)
        case "x":
            return os.path.isfile(file) and os.access(file, X_OK)
        case _:
            return os.path.isfile(file)


def get_confirm() -> None:
    confirmation = input("This will *DESTROY* *ALL* data. Are you sure? Y/n\n")
    if confirmation.lower() == "y":
        return
    exit_gracefully("Canceling as requested.")


def show_help() -> None:
    exit_gracefully(DOCS)


def clear_peer(value: str) -> None:
    delete_peer_out = run_process(f"{KEYDB_CMD_DB} del peer:{value}")
    delete_peerout_out = run_process(f"{KEYDB_CMD_DB} del peerout:{value}")
    exit_gracefully(f"peer:{delete_peer_out}", f"peerout:{delete_peerout_out}")


def clear_user(value: str) -> None:
    delete_user_out = run_process(f"{KEYDB_CMD_DB} del user:{value}")
    delete_userout_out = run_process(f"{KEYDB_CMD_DB} del userout:{value}")
    delete_totaluser_out = run_process(f"{KEYDB_CMD_DB} del totaluser:{value}")
    delete_totaluserout_out = run_process(
        f"{KEYDB_CMD_DB} del totaluserout:{value}"
    )
    exit_gracefully(
        f"user:{delete_user_out}",
        f"userout:{delete_userout_out}",
        f"totaluser:{delete_totaluser_out}",
        f"totaluserout:{delete_totaluserout_out}",
    )
    return


def find(value: str) -> None:
    find_out = run_process(f"{KEYDB_CMD_DB} keys '*' | grep '{value}'")
    exit_gracefully(f"{find_out}")


def _type(key: str) -> None:
    exit_gracefully(_get_key_type(key))


def get(key: str) -> None:
    get_out = run_process(f"{KEYDB_CMD_DB} GET {key}")
    exit_gracefully(f"{get_out}")


def get_list(key: str) -> None:
    get_list_out = run_process(f"{KEYDB_CMD_DB} LRANGE {key} 0 -1")
    exit_gracefully(f"{get_list_out}")


def get_set(key: str) -> None:
    get_set_out = run_process(f"{KEYDB_CMD_DB} SMEMBERS {key}")
    exit_gracefully(f"{get_set_out}")


def get_hash(key: str) -> None:
    values = run_process(f"{KEYDB_CMD_DB} HGETALL {key}")
    out = _construct_hash(values.split("\n"))
    exit_gracefully(out)


def delete(key: str) -> None:
    delete_out = run_process(f"{KEYDB_CMD_DB} del {key}")
    exit_gracefully(f"{delete_out}")


def get_peer(value: str) -> None:
    get_peer_out = run_process(f"{KEYDB_CMD_DB} GET peer:{value}")
    get_peerout_out = run_process(f"{KEYDB_CMD_DB} GET peerout:{value}")
    exit_gracefully(
        f"peer:{get_peer_out}",
        f"peerout:{get_peerout_out}",
    )
    exit_gracefully(f"{get_peer_out}", f"{get_peerout_out}")


def peer_call_ids(value: str) -> None:
    call_ids = run_process(f"{KEYDB_CMD_PAIR} KEYS '*' | awk '{{print $1}}'")
    print("peer_callids:")
    for call_id in call_ids.split("\n"):
        count = run_process(
            f"{KEYDB_CMD_PAIR} LRANGE {call_id} 0 -1 | grep -c peer:{value}"
        )
        if int(count) > 0:
            print(call_id)
    exit_gracefully()


def get_user(value: str) -> None:
    get_user_out = run_process(f"{KEYDB_CMD_DB} GET user:{value}")
    get_userout_out = run_process(f"{KEYDB_CMD_DB} GET userout:{value}")
    get_totaluser_out = run_process(f"{KEYDB_CMD_DB} GET totaluser:{value}")
    get_totaluserout_out = run_process(
        f"{KEYDB_CMD_DB} GET totaluserout:{value}"
    )
    exit_gracefully(
        f"user:{get_user_out}",
        f"userout:{get_userout_out}",
        f"totaluser:{get_totaluser_out}",
        f"totaluserout:{get_totaluserout_out}",
    )


def user_call_ids(value: str) -> None:
    call_ids = run_process(f"{KEYDB_CMD_PAIR} KEYS '*' | awk '{{print $1}}'")
    print("user_callids:")
    for call_id in call_ids.split("\n"):
        count = run_process(
            f"{KEYDB_CMD_PAIR} LRANGE {call_id} 0 -1 | grep -c user:{value}"
        )
        if int(count) > 0:
            print(call_id)
    exit_gracefully()


def scan(pattern: str) -> None:
    scan_out = run_process(f"{KEYDB_CMD_DB} --scan --pattern {pattern}")
    exit_gracefully(f"{scan_out}")


def set_pair(key: str, value: str) -> None:
    set_pair_out = run_process(f"{KEYDB_CMD_DB} SET {key} '{value}'")
    exit_gracefully(f"{set_pair_out}")


def _construct_hash(data: list[str]) -> dict[str:str]:
    key_hash = {}
    for key, value in zip(data[0::2], data[1::2]):
        key_hash[key] = value
    return key_hash


def _get_key_value(key: str, key_type: str) -> str | list[str] | dict[str:str]:
    match key_type:
        case KeyType.STRING.value:
            return run_process(f"{KEYDB_CMD_DB} GET {key}")
        case KeyType.LIST.value:
            values = run_process(f"{KEYDB_CMD_DB} LRANGE {key} 0 -1")
            return values.split("\n")
        case KeyType.SET.value:
            values = run_process(f"{KEYDB_CMD_DB} SMEMBERS {key}")
            return values.split("\n")
        case KeyType.HASH.value:
            values = run_process(f"{KEYDB_CMD_DB} HGETALL {key}")
            return _construct_hash(values.split("\n"))
        case KeyType.ZSET.value:
            values = run_process(
                f"{KEYDB_CMD_DB} ZRANGE {key} 0 -1 WITHSCORES"
            )
            return _construct_hash(values.split("\n"))
        case _:
            return KeyType.NONE.value


def _get_key_type(key: str) -> str:
    return run_process(f"{KEYDB_CMD_DB} type {key}")


def _read_key(key: str) -> dict[str, str]:
    key_type = _get_key_type(key)
    key_value = _get_key_value(key, key_type)
    return {"type": key_type, "value": key_value}


def _encode_JSON(target: dict[str, any]) -> str:
    encoder = json.JSONEncoder(**JSON_ENCODER_OPTIONS)
    return encoder.encode(target)


def _decode_JSON(target: str) -> dict[str:str]:
    decoder = json.JSONDecoder()
    return decoder.decode(target)


def dumps(return_dump: bool = False) -> str | None:
    keys = run_process(f"{KEYDB_CMD_DB} keys '*'")
    if not keys:
        exit_with_error("Database is empty")
    keys = keys.split("\n")
    if EXTENDED:
        key_dump = {}
        for key in keys:
            key_dump[key] = _read_key(key)
        encoded_dump = _encode_JSON(key_dump)
    else:
        encoded_dump = _encode_JSON(keys)
    if not return_dump:
        exit_gracefully(f"{encoded_dump}")
    return encoded_dump


def dump(path: str) -> None:
    dbname = f"{path}/keydb_dump_db_{DB}.json"
    try:
        with open(dbname, "w", encoding="utf-8") as file:
            file.write(dumps(True))
    except OSError as error:
        exit_with_error(f"Error: {error.strerror}")


def set_key(
    key: str, key_type: str, value: str | list[str] | dict[str:str]
) -> None:
    match key_type:
        case KeyType.STRING.value:
            run_process(f"{KEYDB_CMD_DB} SET {key} '{value}'")
            return
        case KeyType.LIST.value:
            for item in value:
                run_process(f"{KEYDB_CMD_DB} LPUSH {key} '{item}'")
            return
        case KeyType.SET.value:
            for item in value:
                run_process(f"{KEYDB_CMD_DB} SADD {key} '{item}'")
            return
        case KeyType.HASH.value:
            for hash_key, hash_value in value.items():
                run_process(
                    f"{KEYDB_CMD_DB} HSET {key} {hash_key} '{hash_value}'"
                )
        case KeyType.ZSET.value:
            for element, score in value.items():
                run_process(f"{KEYDB_CMD_DB} ZADD {key} {score} '{element}'")
        case _:
            return


def load_data(data: dict[str, dict]) -> None:
    for key, value in data.items():
        set_key(key, value["type"], value["value"])
    return


def load(path: str) -> None:
    data = {}
    try:
        with open(f"{path}", "r", encoding="utf-8") as file:
            data = _decode_JSON(file.read())
    except OSError as error:
        exit_with_error(f"Error: {error.strerror}")
    finally:
        load_data(data)
        exit_gracefully("OK")


def flush_db() -> None:
    get_confirm()
    flush_db_out = run_process(f"{KEYDB_CMD_DB} flushdb")
    exit_gracefully(f"{flush_db_out}")


def get_configs() -> None:
    global HOST, DB, PAIR, KEYDB_CMD_DB, KEYDB_CMD_PAIR
    if check_file("/usr/share/ngcp-ngcpcfg/scripts/values"):
        HOST = run_process("ngcpcfg get database.central.dbhost")
        DB = run_process("ngcpcfg get redis.db.kamailio.proxy.dlgcnt_central")
        PAIR = run_process("ngcpcfg get redis.db.kamailio.proxy.dlgcnt_pair")
    else:
        HOST = run_process("ngcp-nodename")
        DB = 3
        PAIR = 4

    if is_regex_match(HOST, RE_HOST):
        if check_file("/etc/keydb/keydb.conf", "r"):
            HOST = run_process(
                "awk '/^bind/ {print $NF}' /etc/keydb/keydb.conf"
            )
        else:
            exit_with_error(
                "Error: couldn't read /etc/keydb/keydb.conf, giving up."
            )
    return


def parse_input_options(options: list[str]) -> None:
    global HOST, DB, KEYDB_CMD_DB, KEYDB_CMD_PAIR, EXTENDED
    for option, option_argument in options:
        if option in ("-h", "--host"):
            HOST = option_argument
        if option in ("-n", "--keydb_db"):
            DB = option_argument
        if option in ("-e"):
            EXTENDED = True

    KEYDB_CMD_DB = f"keydb-cli -h {HOST} -n {DB}"
    KEYDB_CMD_PAIR = f"keydb-cli -h {HOST} -n {PAIR}"


def parse_input_arguments(arguments: list[str]) -> None:
    if len(arguments) > 0 and arguments[0] not in ARGS:
        exit_with_error(
            f"ERROR: action {arguments[0]} is not supported!", DOCS
        )
    match len(arguments):
        case 3:
            match arguments[0]:
                case "set":
                    return set_pair(arguments[1], arguments[2])
                case _:
                    return
        case 2:
            match arguments[0]:
                case "clearpeer":
                    return clear_peer(arguments[1])
                case "clearuser":
                    return clear_user(arguments[1])
                case "find":
                    return find(arguments[1])
                case "dump":
                    return dump(arguments[1])
                case "get":
                    return get(arguments[1])
                case "get_hash":
                    return get_hash(arguments[1])
                case "get_list":
                    return get_list(arguments[1])
                case "get_set":
                    return get_set(arguments[1])
                case "del":
                    return delete(arguments[1])
                case "getpeer":
                    return get_peer(arguments[1])
                case "load":
                    return load(arguments[1])
                case "peer_callids":
                    return peer_call_ids(arguments[1])
                case "getuser":
                    return get_user(arguments[1])
                case "user_callids":
                    return user_call_ids(arguments[1])
                case "scan":
                    return scan(arguments[1])
                case "type":
                    return _type(arguments[1])
                case _:
                    exit_with_error(
                        f"ERROR: Missed arg for command '{arguments[0]}'!",
                        DOCS,
                    )
            return

        case 1:
            match arguments[0]:
                case "help":
                    show_help()
                case "dumps":
                    dumps()
                case "flushdb":
                    flush_db()
                case _:
                    exit_with_error(
                        f"ERROR: Missed arg for command '{arguments[0]}'!",
                        DOCS,
                    )
        case _:
            return


def parse_user_input(args: list[str]) -> None:
    options: list[str] = []
    arguments: list[str] = []
    try:
        options, arguments = getopt.getopt(
            args, ":h:n:e", ["host=", "keydb_db="]
        )
        get_configs()
        parse_input_options(options)
        parse_input_arguments(arguments)
    except getopt.GetoptError as error:
        exit_with_error(f"ERROR: {error.msg}", DOCS)


def main(args: list[str]) -> None:
    parse_user_input(args)


if __name__ == "__main__":
    main(sys.argv[1:])
