import asyncio
import base64
import fnmatch
import inspect
import os
import pprint as core_pprint
import re
import time
from asyncio.subprocess import Process
from typing import Any, Dict, List, Optional, Set, get_args

import aiofiles
from yaml import BaseLoader, load

from . import app_config

node_state: str = ''
node_state_last_modify: float = 0
node_state_update_interval = 30


class SimpleObj:
    """Stub class that is used in dict2obj().

    Args:
        None

    Returns
        None

    """
    pass


async def read_yaml(file_name: str) -> Any:
    """Reads yaml file.

    Args:
        file_name (str): yaml filename (absolute path)

    Returns
        Any: loaded yaml data

    """
    async with aiofiles.open(file_name, 'r',
                             loop=asyncio.get_running_loop(),
                             encoding='utf-8') as file:
        return load(await file.read(), BaseLoader)


def pprint(obj: object) -> None:
    """Prints an object in a readable form.

    Args:
        obj: (object): object to print

    Returns
        None

    """
    core_pprint.pprint(obj)


def compact_str(in_str: str) -> str:
    """Truncates all whitespaces and newlines down to one space.

    If the string length exceeds 999 characters, the string is truncated and an
    ellipsis is appended.

    Args:
        in_str: (str): input string

    Returns
        str: compact string

    """
    ret = re.sub(r'\s{2,}|\n', ' ', in_str, flags=re.MULTILINE)
    if len(ret) <= 999:
        return ret
    return ret[0:999] + '...'


def class_name(obj: object) -> str:
    """Fetches a class name from an object.

    Args:
        obj: (object): class object

    Returns
        str: class name

    """
    cls = obj.__class__
    mod = cls.__module__
    if mod == '__builtin__':
        return cls.__name__
    return f'{mod.split(".")[0]}.{cls.__name__}'


def short_class_name(obj: object) -> str:
    """Fetches a short class name.

    Removes any parent package indications

    Args:
        obj: (object): class object

    Returns
        str: short class name

    """
    return obj.__class__.__name__


def dict2obj(my_dict: Dict[Any, Any]) -> SimpleObj:
    """Recursive function that converts a provided Dict to an object.

    The resulting object can be called as obj.attr instead of dict['attr']

    Note:
        IDE's class attributes auto suggestions will not work
        with this as of dynamically created class attributes

    Args:
        my_dict: (Dict): input dict

    Returns
        SimpleObj: an object containing the input dict's
            keys as attributes

    """
    if isinstance(my_dict, list):
        my_dict = [dict2obj(x) for x in my_dict]

    if not isinstance(my_dict, dict):
        return my_dict

    obj = SimpleObj()

    for k in my_dict:
        obj.__dict__[k] = dict2obj(my_dict[k])

    return obj


def str_to_bool(my_str: str) -> bool:
    """Converts provided string value into bool.

    Converts string representation of 'True','False','Yes','No'
    (case insentisive) and 1, 0 into True/False accordingly

    Args:
        my_str: (str): string to check for boolean

    Returns
        bool: True if string is boolean False otherwise

    """
    str_is_bool: bool = my_str.lower() in ('true', 'yes', '1')
    return str_is_bool


def apply_dict(my_dict: Dict[Any, Any], my_obj: object) -> None:
    """Applies a dict to a similarly structured object.

    Casts the dict's value types to the ones defined in the object

    The object must contain and have all the values,
        except Dict or Array properly typed

    Dict and Array values are copied as they are

    Args:
        my_dict (dict): dictionary to apply
        my_obj (object): object to update from dict

    Returns
        None
    """
    cls = my_obj.__class__
    cls_attrs: Dict[str, type] = {}
    while '__annotations__' in cls.__dict__:
        cls_attrs = cls.__annotations__ | cls_attrs
        if not cls.__base__:
            break
        cls = cls.__base__

    for key, val in my_dict.items():
        if key not in cls_attrs.keys():
            raise ValueError(compact_str(
                f'apply_dict(): class={my_obj.__class__.__name__} \
                  attr={key} is not annotated in the object'
            ))
        val_type = cls_attrs[key]
        if isinstance(val, Dict) and inspect.isclass(val_type):
            my_obj.__dict__[key] = val_type()
            apply_dict(val, my_obj.__dict__[key])
        elif isinstance(val, Dict):
            my_obj.__dict__[key] = val
        elif isinstance(val, List):
            arg_type = get_args(val_type)[0]
            list_val = my_obj.__dict__[key] = []
            for element in val:
                list_entry = arg_type()
                apply_dict(element, list_entry)
                list_val.append(list_entry)
        else:
            my_obj.__dict__[key] = val_type(val)


async def exec_command(cmd: str,
                       args: str,
                       as_user: str,
                       stdin: bytes | None = None
                       ) -> tuple[bytes, bytes, Optional[int]]:
    """Executes a command (script).

    Args:
        cmd (str): command (script) to execute
        args (str): command arguments
        as_user (str): execute the command as user
        stdin (bytes): optional stdin to use for command

    Returns
        tuple[bytes, bytes, Optional[int]]: stdout, stderr, exit code

    """
    proc: Process = await asyncio.create_subprocess_exec(
        cmd,
        *args.split(),
        stdin=asyncio.subprocess.PIPE if stdin else None,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
        user=as_user,
    )
    stdout, stderr = await proc.communicate(stdin)
    return stdout, stderr, proc.returncode


async def get_node_state() -> str:
    """Fetches the provided node state.

    state: ['active','standby','unknown','transition']

    Args:
        None

    Returns
        str: the state string

    """
    if app_config.dev_mode:
        return 'active'

    global node_state
    global node_state_last_modify
    c_time = time.time()
    if node_state_last_modify+node_state_update_interval <= c_time:
        try:
            stdout, stderr, _code = await exec_command(
                '/usr/sbin/ngcp-check-active', '-v', 'root'
            )
        except OSError as err:
            if err.strerror:
                stderr = bytes(err.strerror, 'latin1')
            else:
                stderr = b'unknown exception'
        if stderr:
            node_state = 'unknown'
        else:
            node_state = bytes.decode(stdout).strip()
        node_state_last_modify = c_time
    return node_state


def parse_dst(dst: str) -> Dict[str, Dict[str, str | List[str]]]:
    """Parses provided dst string and returns parsed node/opts Dict.

    Supported dst syntax:
        - <node>[|options key=value[;key=valye]][,....]
        - node names support 'fnmatch' syntax, e.g.: prx01? or lb*
        - '|' is to separate node name from the node options (optional)
        - ';' option key=value separator
        - 'role' option supports multiple roles separated by '+'
        - in case of 'local' or 'localhost', provided src must be equal to
          app_config.ngcp.node_name

    Args:
        dst (str): destination string

    Returns
        Dict[str, Dict[str, str | List[str]]: Map: node <> opts

    """
    parsed_dst: Dict[str, Dict[str, str | List[str]]] = {}

    dst_list = [dst.strip() for dst in dst.split(',')]
    for d_arg in dst_list:
        d_node: str = ''
        d_opts: str = ''
        opts: Dict[str, str | List[str]] = {}

        if '|' in d_arg:
            d_node, d_opts = d_arg.split('|')
        else:
            d_node = d_arg

        if d_opts:
            t_opts: List[str] = []
            if ';' in d_opts:
                t_opts = d_opts.split(';')
            else:
                t_opts.append(d_opts)
            for opt in t_opts:
                if '=' in opt:
                    key, value = opt.split('=')
                    opts[key] = value
                    if key == 'role':
                        opts[key] = [value]
                        if '+' in value:
                            opts[key] = value.split('+')

        parsed_dst[d_node] = opts

    return parsed_dst


async def check_dst_is_own(src: str, dst: str) -> bool:
    """Checks if the provided dst matches the current host.

    Also checks the options pattern from the available options
        status: ['online','inactive','offline']
        state: ['active','standby','unknown','transition']
        role: ['proxy','lb','db','rtp','mgmt', ... check agent.conf]

    Supported dst syntax:
        - <node>[|options key=value[;key=valye]][,....]
        - node names support 'fnmatch' syntax, e.g.: prx01? or lb*
        - '|' is to separate node name from the node options (optional)
        - ';' option key=value separator
        - 'role' option supports multiple roles separated by '+'
        - in case of 'local' or 'localhost', provided src must be equal to
          app_config.ngcp.node_name

    Examples:
        - local
        - localhost
        - sp1
        - sp1,sp2
        - lb*a|state=active,db01a|role=db
        - prx0??|status=online;state=standby
        - *|status=online;role=proxy
        - *|status=online;role=proxy+lb+rtp

    Args:
        src (str): source node string
        dst (str): destination string

    Returns
        bool: true if matches, false otherwise

    """
    own: str = app_config.ngcp.node_name
    found: bool = False

    parsed_dst = parse_dst(dst)
    for node, opts in parsed_dst.items():
        regex = fnmatch.translate(node)
        rx = re.compile(regex)
        if node in ('local', 'localhost'):
            if src != app_config.ngcp.node_name:
                continue
        elif not rx.match(own):
            continue

        found = True

        for opt, val in opts.items():
            if opt == 'state':
                if val != await get_node_state():
                    found = False
                    break
            elif opt == 'status':
                if val != app_config.ngcp.status:
                    found = False
                    break
            elif opt == 'role':
                role_found = False
                for role_val in val:
                    if role_val in app_config.ngcp.role.keys():
                        if app_config.ngcp.role[role_val] == 'yes':
                            role_found = True
                            break
                if not role_found:
                    found = False
                    break

        if found:
            return True

    return False


async def get_dst_nodes(dst: str) -> Set[str]:
    """Returns a set of nodes that match the provided dst string.

    Also checks the options pattern from the available options
        state: if the state option is provided,
               then only the pair names are returned, otherwise
               the node names are returned
        role: ['proxy','lb','db','rtp','mgmt', ... check agent.conf]

    Supported dst syntax:
        - <node>[|options key=value[;key=valye]][,....]
        - node names support 'fnmatch' syntax, e.g.: prx01? or lb*
        - '|' is to separate node name from the node options (optional)
        - ';' option key=value separator
        - 'role' option supports multiple roles separated by '+'
        - in case of 'local' or 'localhost', provided src must be equal to
          app_config.ngcp.node_name

    Examples:
        - local
        - localhost
        - sp1
        - sp1,sp2
        - lb*a|state=active,db01a|role=db
        - prx0??|status=online;state=standby
        - *|status=online;role=proxy
        - *|status=online;role=proxy+lb+rtp

    Args:
        dst (str): destination string

    Returns
        Set[str]: a set containig unique node names

    """
    nodes_list: List[str] = []

    parsed_dst = parse_dst(dst)
    for node, opts in parsed_dst.items():
        only_pairs: bool = False
        only_roles: Set[str] = set()
        for opt, val in opts.items():
            if opt == 'state':
                only_pairs = True
            elif opt == 'role':
                only_roles = set(val)

        regex = fnmatch.translate(node)
        rx = re.compile(regex)
        if node in ('local', 'localhost'):
            if node == app_config.ngcp.node_name:
                if len(only_roles):
                    for role in only_roles:
                        if role in app_config.ngcp.role:
                            if app_config.ngcp.role[role] == 'yes':
                                nodes_list.append(node)
                else:
                    nodes_list.append(node)
        else:
            for role, nodes in app_config.ngcp.roles.items():
                for node_elem in nodes:
                    if rx.match(node_elem['node']):
                        d_node = node_elem['pair' if only_pairs else 'node']
                        if only_roles and role in only_roles:
                            nodes_list.append(d_node)
                        else:
                            nodes_list.append(d_node)
    return set(nodes_list)


def apply_str_macros(macro_str: str, macros: Dict[str, Any]) -> str:
    """Applies macros to a string.

    Takes a string that contains macros as ${some_macro} ${another}
    and replaces them with the provided values from the macros Dict

    Any macros in the string that are are left applied, removed in the end

    Args:
        macro_str (str): a string that contains macros
        macros (Dict[str, Any]): macros to apply

    Returns
        str: string with applied macros

    """
    new_str: str = macro_str
    for key, val in macros.items():
        if isinstance(val, str):
            new_str = new_str.replace('${'+key+'}', val)
        elif isinstance(val, int) or isinstance(val, float):
            new_str = new_str.replace('${'+key+'}', str(val))

    new_str = re.sub(r'\${[a-z0-9]+}', '', new_str, flags=re.IGNORECASE)
    return new_str


def apply_dict_macros(d: Dict[str, Any], data: Any) -> None:
    """Applies macros to a dict.

    Iterates a dict and looks for values to replace. Only entire values are
    matched and replace, i.e. there is no support for substring matching or
    substitution.

    At this time only ${data} and ${data.binary} are supported. More generic
    matching, with a dict as "data", can be added in the future.

    Macros:
        ${data}: "data" can be any arbitrary value or object and the value
            is replaced directly
        ${data.binary}: "data" must be a string in base64 format, and the
            value is replaced with the decoded binary data
        ${...}: generic macro via apply_str_macros()

    Args:
        d (dict): dict to iterate and replace values in
        data (any): data used for substitution

    Returns:
        None

    """
    for key in d:
        d[key] = apply_generic_macros(d[key], data)


def apply_generic_macros(obj: Any, data: Any) -> Any:
    """Helper function for apply_dict_macros.

    Does the actual work of replacing values, and recurses into lists and
    dicts.

    Args:
        obj (any): something to look for macro variables in. Can be a string,
            list, or dict. Other types are left untouched
        data (any): replacement value(s) for macro variables

    Returns:
        any: "obj" with replacements performed
    """
    if isinstance(obj, str):
        if obj == '${data}':
            return data
        elif obj == '${data.binary}' and isinstance(data, str):
            return base64.b64decode(data)
        elif isinstance(data, dict):
            return apply_str_macros(obj, data)
    elif isinstance(obj, dict):
        apply_dict_macros(obj, data)
        return obj
    elif isinstance(obj, list):
        nl: List[Any] = []
        for ele in obj:
            nl.append(apply_generic_macros(ele, data))
        return nl
    return obj
