import abc
import json
import os
import re
import subprocess
from collections import defaultdict
from dataclasses import dataclass, field, fields

import yaml
from hotsos.core.config import HotSOSConfig
from hotsos.core.host_helpers.exceptions import (
    catch_exceptions,
    CLI_COMMON_EXCEPTIONS,
    SourceNotFound,
)
from hotsos.core.log import log


@dataclass(frozen=True)
class CmdOutput():
    """ Representation of the output of a command. """

    # Output value.
    value: str
    # Optional command source path.
    source: str = None


def run_pre_exec_hooks(f):
    """ pre-exec hooks are run before running __call__ method.

    These hooks are not expected to return anything and are used to manipulate
    the instance variables used by the main __call__ method.
    """
    def run_pre_exec_hooks_inner(self, *args, **kwargs):
        hook = self.hooks.get("pre-exec")
        if hook:
            # no return expected
            hook(*args, **kwargs)

        return f(self, *args, **kwargs)

    return run_pre_exec_hooks_inner


def run_post_exec_hooks(f):
    """ post-exec hooks are run after running __call__ method and take its
    output as input.
    """
    def run_post_exec_hooks_inner(self, *args, **kwargs):
        out = f(self, *args, **kwargs)
        hook = self.hooks.get("post-exec")
        if hook:
            out = hook(out, *args, **kwargs)

        return out

    return run_post_exec_hooks_inner


def reset_command(f):
    """
    This should be run by all commands as their last action after all/any hooks
    have run.
    """
    def reset_command_inner(self, *args, **kwargs):
        out = f(self, *args, **kwargs)
        self.reset()
        return out

    return reset_command_inner


class CmdAffinity:
    """
    Supports command affinity. By default all commands support affinity so that
    we can run the last-known-good command source without having to try all
    variants first.
    """

    def __init__(self, info):
        self.info = info

    def matches(self, cmdkey):
        """
        Return true if the provided cmdkey has an affinity match for this
        object.
        """
        if not self.info:
            return False

        cls, value = self.info
        if value in (cls.CMD_AFFINITY or {}).get(cmdkey, []):
            log.debug("affinity match found: %s='%s' (%s)", cmdkey,
                      value, cls.__name__)
            return True

        return False

    def set(self, cmdkey):
        """
        Set affinity path/cmd for cmdkey. This allows us to use a
        last-known-good source so we don't have to retry ones that didn't work.
        """
        if (not self.info or
                os.environ.get('HOTSOS_DISABLE_AFFINITY') == 'True'):
            return

        cls, value = self.info
        if cls.CMD_AFFINITY is None:
            cls.CMD_AFFINITY = defaultdict(list)

        if (cmdkey not in cls.CMD_AFFINITY or
                value not in cls.CMD_AFFINITY[cmdkey]):
            log.debug("setting affinity %s='%s' (%s)", cmdkey, value,
                      cls.__name__)
            cls.CMD_AFFINITY[cmdkey].append(value)
        else:
            log.debug("%s '%s' affinity already exists (%s)", cls.__name__,
                      cmdkey, ', '.join(cls.CMD_AFFINITY[cmdkey]))


@dataclass
class CmdBase:
    """ Base class for all command source types.

    Provides a way to save original state and restore to that state.
    """
    CMD_AFFINITY = None

    def __post_init__(self):
        self.affinity = CmdAffinity(self._affinity_info)
        self.hooks = {}
        # make a copy of field original values.
        for f in fields(self):
            setattr(self, 'original_' + f.name, f.type(getattr(self, f.name)))

    @property
    @abc.abstractmethod
    def _affinity_info(self):
        """ All command types must implement this. Returns information needed
        to set affinity.

        To disable affinity for a command this must return an empty tuple()

        :return: tuple of (class, value) where class is the implementation of
                 CmdBase we want to register the affinity with and value is the
                 command identifier e.g. path or cli command.
        """

    def get_original_attr_value(self, name):
        return getattr(self, 'original_' + name)

    def reset(self):
        """ Reset fields to original values. """
        for f in fields(self):
            setattr(self, f.name, f.type(getattr(self, 'original_' + f.name)))

    @classmethod
    def safe_readlines(cls, path):
        with open(path, 'r', encoding='utf-8', errors="surrogateescape") as fd:
            return fd.readlines()

    def register_hook(self, name, f):
        """
        Implementations of this class can register hooks to
        be run when the __call__ method is executed. Currently
        supported hooks include:

            * pre-exec - run before __call__ method.
            * post-exec - run after __call__ method and take its output as
                          input.
        """
        self.hooks[name] = f


@dataclass
class FileCmdBase(CmdBase):
    """
    State used to execute a file-based command i.e. a command whose output is
    already saved in a file.
    """
    path: str
    json_decode: bool = False
    yaml_decode: bool = False
    singleline: bool = False
    decode_error_handling: str = None

    @property
    def _affinity_info(self):
        return self.__class__, self.path

    def __post_init__(self):
        self.path = os.path.join(HotSOSConfig.data_root, self.path)
        super().__post_init__()


@dataclass
class BinCmdBase(CmdBase):
    """ State used to execute a binary command. """
    cmd: str
    json_decode: bool = False
    yaml_decode: bool = False
    singleline: bool = False
    cmd_extras: list = field(default_factory=lambda: [])

    @property
    def _affinity_info(self):
        return self.__class__, self.cmd


class BinCmd(BinCmdBase):
    """ Implements binary command execution. """
    TYPE = "BIN"

    def json_decode_output(self, output):  # pylint: disable=no-self-use
        """
        Decode JSON output.

        This allows implementations to override the decoding if necessary.
        """
        return CmdOutput(json.loads(output))

    @catch_exceptions(*CLI_COMMON_EXCEPTIONS)
    @reset_command
    @run_post_exec_hooks
    @run_pre_exec_hooks
    def __call__(self, *args, skip_json_decode=False, **kwargs):
        cmd = self.cmd
        if args:
            cmd = cmd.format(*args)

        if kwargs:
            cmd = cmd.format(**kwargs)

        _cmd = cmd.split() + self.cmd_extras
        log.debug("executing cli command: %s", _cmd)
        out = subprocess.run(_cmd, timeout=HotSOSConfig.command_timeout,
                             capture_output=True, check=False)
        output = out.stdout
        if out.stderr:
            output += out.stderr

        if out.returncode != 0:
            log.info("command '%s' exited with non-zero code '%s'", cmd,
                     out.returncode)

        try:
            output = output.decode('UTF-8')
        except UnicodeDecodeError:
            log.exception("failed to decode command output for '%s'", cmd)
            output = ''

        if self.json_decode and not skip_json_decode:
            return self.json_decode_output(output)

        if self.yaml_decode:
            return CmdOutput(yaml.safe_load(output))

        if self.singleline:
            return CmdOutput(output.strip())

        return CmdOutput(output.splitlines(keepends=True))


class FileCmd(FileCmdBase):
    """ Implements file-based command execution.

    This is used e.g. with sosreports where the output of a command is saved
    to disk.
    """
    TYPE = "FILE"

    @catch_exceptions(*CLI_COMMON_EXCEPTIONS)
    @reset_command
    @run_post_exec_hooks
    @run_pre_exec_hooks
    def __call__(self, *args, skip_load_contents=False, **kwargs):
        if args:
            self.path = self.path.format(*args)

        if kwargs:
            self.path = self.path.format(**kwargs)

        if not os.path.exists(self.path):
            raise SourceNotFound(self.path)

        if skip_load_contents:
            CmdOutput(None, self.path)

        # NOTE: any post-exec hooks must be aware that their input will be
        # defined by the following.
        if self.json_decode:
            with open(self.path, encoding='utf-8') as fd:
                output = json.load(fd)
        elif self.yaml_decode:
            with open(self.path, encoding='utf-8') as fd:
                output = yaml.safe_.load(fd)
        else:
            output = []
            ln = 0
            with open(self.path, 'rb') as fd:
                for line in fd:
                    ln += 1
                    try:
                        decode_kwargs = {}
                        if self.decode_error_handling:
                            decode_kwargs['errors'] = \
                                                     self.decode_error_handling

                        _out = line.decode(**decode_kwargs)
                        output.append(_out)
                    except UnicodeDecodeError:
                        # maintain line count but store empty line.
                        # we could in the future consider other decode options
                        # as a fallback.
                        output.append('')
                        log.exception("failed to decode line %s "
                                      "(decode_error_handling=%s)", ln,
                                      self.decode_error_handling)

                    if self.singleline:
                        break

            if self.singleline:
                return CmdOutput(output[0].strip(), self.path)

        return CmdOutput(output, self.path)


class BinFileCmd(FileCmd):
    """ This is used when we are executing an actual binary/command against a
    file. """

    @catch_exceptions(*CLI_COMMON_EXCEPTIONS)
    @reset_command
    @run_post_exec_hooks
    @run_pre_exec_hooks
    def __call__(self, *args, **kwargs):
        # NOTE: we check the original path since by this point the 'path'
        # attribute will have been modified to include a binary and other args
        # required to execute it.
        if not os.path.exists(self.get_original_attr_value('path')):
            raise SourceNotFound(self.get_original_attr_value('path'))

        if args:
            self.path = self.path.format(*args)

        if kwargs:
            self.path = self.path.format(**kwargs)

        # If this file is part of a sosreport we want to make sure it is run
        # in the same timezone context as the sosreport host.
        env = {}
        try:
            env['TZ'] = DateFileCmd('sos_commands/date/date',
                                    singleline=True)(format="+%Z").value
        except SourceNotFound:
            pass

        # Now split into a command and run
        output = subprocess.check_output(self.path.split(),
                                         timeout=HotSOSConfig.command_timeout,
                                         stderr=subprocess.STDOUT, env=env)

        output = output.decode('UTF-8')
        return CmdOutput(output.splitlines(keepends=True))


class DateFileCmd(FileCmd):
    """ Implementation of file-based date command. """
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.register_hook("post-exec", self.format_date)

    def format_date(self, output, **kwargs):
        """ Apply some post-processing to the date output.

        @param output: CmdOutput object
        """
        no_format = kwargs.get('no_format', False)
        fmt = kwargs.get('format')
        if not no_format and fmt is None:
            fmt = '+%s'

        ret = re.match(r"^(\S+ \S*\s*[0-9]+ [0-9:]+)\s*"
                       r"([A-Z]*|[+-]?[0-9]*)?"
                       r"\s*([0-9]+)$",
                       output.value)

        if ret is None:
            log.error("%s has invalid date string '%s'", self.path,
                      output.value)
            return CmdOutput('', self.path)

        tz = ret[2]
        # NOTE: date command doesn't recognise HKT for some reason so we
        # convert to a format that is recognised.
        if tz == 'HKT':
            tz = 'UTC+8'

        # Include tz if available and then convert to utc
        date = f"{ret[1]} {tz} {ret[3]}"
        cmd = ["date", "--utc", f"--date={date}"]
        if fmt:
            cmd.append(fmt)

        output = subprocess.check_output(cmd,
                                         timeout=HotSOSConfig.command_timeout)
        # date sometimes adds multiple whitespaces between fields so collapse
        # them.
        output = re.compile(r"\s+").sub(' ', output.decode('UTF-8'))
        ret = output.splitlines(keepends=True)[0]
        # always singleline so always strip trailing newline
        return CmdOutput(ret.strip(), self.path)
