from typing import (
    Union,
    Dict,
    Iterator,
    List,
)

from pathlib import Path

from ._common_imports import (
    classad,
    handle_t,
)

from collections.abc import MutableMapping

from .htcondor2_impl import (
    _submit_init,
    _submit__getitem__,
    _submit__setitem__,
    _submit_keys,
    _submit_expand,
    _submit_getqargs,
    _submit_setqargs,
    _submit_from_dag,
    _display_dag_options,
    _submit_set_submit_method,
    _submit_get_submit_method,
    _submit_issue_credentials,
    _submit_itemdata,
    HTCondorException,
)

from ._submit_method import SubmitMethod

DefaultItemData = object()

#
# MutableMapping provides generic implementations for all mutable mapping
# methods except for __[get|set|del]item__(), __iter__, and __len__.
#

class Submit(MutableMapping):

    # This is exceedingly clumsy, but matches the semantics from version 1.
    def __init__(self,
        input : Union[Dict[str, str], str] = None,
        queue : str = None,
        ** kwargs
    ):
        '''
        An object representing a job submit description.  It uses the same
        language as *condor_submit.*  Values in the submit description language
        have no type; they are all strings.

        If `input` is a dictionary, it will be serialized into the submit
        language.  Otherwise, `input` is a string in the submit language.
        This string may end with a queue statement.  You may not specify a
        queue statement in an `input` dictionary; use the `queue` keyword
        argument to specify its arguments, instead.  (You may preface the
        the keyword's string value with "queue ", if you prefer.)

        If other keyword arguments are supplied, they will update this object
        after the submit-language string, if any, is parsed.

        This object implements the Python dictionary protocol, except
        that attempts to ``del()`` entries will raise a
        :class:`NotImplementedError`
        because the underlying logic does not distinguish between a
        missing key and a key whose value is the empty string.  (This seems
        better than allowing you to ``del(s[key])`` but having ``key`` still
        appear in ``s.keys()``.)
        '''
        self._handle = handle_t()

        # if isinstance(input, dict) and len(input) != 0:
        if isinstance(input, dict):
            pairs = []
            for key,value in input.items():
                if not isinstance(key, str):
                    raise TypeError("key must be a string")
                if key.casefold() == "queue".casefold():
                    raise ValueError("the queue statement can not be specified in a dictionary")
                # This was an undocumented feature in version 1, and it's
                # likely to be useless.  This implementation assumes that
                # str(classad.ExprTree) is always valid in a submit-file.
                if isinstance(value, classad.ClassAd):
                    pairs.append(f"{key} = {repr(value)}")
                else:
                    # In version 1, we (implicitly) document the dict as
                    # being from strings to strings, but don't enforce it;
                    # the code basically just calls str() on the value and
                    # hopes for the best.  This is probably a mistake in
                    # general, but it's really convenient, and Ornithology
                    # depends on being able to submit bools and ints as
                    # those types.  Luckily, those are easy to check for
                    # specifically and unambiguous to handle.
                    if isinstance(value, int):
                        value = str(value)
                    elif isinstance(value, float):
                        value = str(value)
                    elif isinstance(value, bool):
                        value = str(value)
                    # This is cheating, but we use it _a lot_ in the test suite.
                    elif isinstance(value, Path):
                        value = str(value)
                    elif not isinstance(value, str):
                        raise TypeError("value must be a string")
                    pairs.append(f"{key} = {value}")
            s = "\n".join(pairs)
            _submit_init(self, self._handle, s)
        elif isinstance(input, str):
            _submit_init(self, self._handle, input)
        # elif input is None or isinstance(input, dict) and len(input) == 0:
        elif input is None:
            _submit_init(self, self._handle, None)
        else:
            raise TypeError("input must be a dictionary mapping string to strings, a string, or None")

        self.update(kwargs)

        if queue is not None:
            if isinstance(queue, str):
                self.setQArgs(queue)
            else:
                raise TypeError("queue must be a string")


    def __getitem__(self, key):
        if not isinstance(key, str):
            raise TypeError("key must be a string")
        return _submit__getitem__(self, self._handle, key)


    def __setitem__(self, key, value):
        if not isinstance(key, str):
            raise TypeError("key must be a string")
        if not isinstance(value, str):
            raise TypeError("value must be a string")
        return _submit__setitem__(self, self._handle, key, value)


    def __delitem__(self, key):
        # We can actually fake this (using NULL values), but should we?
        raise NotImplementedError("Submit object keys can not be removed, but the empty string is defined to have no effect.")


    #
    # Python's documentation is confusing and somewhat self-contradictory,
    # but the build-in `dict` tries really hard to raise a `RuntimeError`
    # if the dictionary is modified while iterating.
    #
    # This suggests that it's OK for __iter__ to get the list of keys
    # and then `yield` them one-at-a-time.  This is good, since the C++
    # `SubmitHash` almost certainly can't handle anything else.
    #
    # In version 1, keys(), items(), and values() all explicitly ignored
    # the submit hash's default values, but __getitem__() did not.  This
    # was madness.  Implementing htcondor2.Submit as a MutableMapping
    # subclass eliminates the possibility of those functions drifting out
    # synch with __iter__(); the current implementation of __len__() is
    # slow, but should also always agree with whatever we do in __iter__().
    #
    # It would be possible to change __getitem__() to ignore the default
    # values as well (in C++, or less efficiently in Python), but that
    # doesn't seem helpful.
    #
    # Ignoring default values actually turns out to be necessary to correctly
    # (re)generate submit files from submit hashes.
    #
    def __iter__(self):
        keys = _submit_keys(self, self._handle)
        if keys is not None:
            for key in keys.split('\0'):
                yield(key)


    def __len__(self):
        keys = _submit_keys(self, self._handle)
        if keys is None:
            return 0
        return len(keys.split('\0'))


    def __str__(self):
        rv = ''
        for key, value in self.items():
            rv = rv + f"{key} = {value}\n"
        qargs = _submit_getqargs(self, self._handle)
        if qargs is not None:
            if qargs == '':
                rv = rv + "queue\n"
            else:
                rv = rv + "queue " + qargs + "\n"
        return rv


    def __repr__(self):
        return f'Submit({self})'


    def expand(self, attr : str) -> str:
        '''
        Expand all macros for the given attribute.

        :param str attr: The name of the attribute to expand.
        :return: The value of the given attribute, with all macros expanded.
        :rtype: str
        '''
        if not isinstance(attr, str):
            raise TypeError("attr must be a string")
        return _submit_expand(self, self._handle, attr)


    # In version 1, the documentation widly disagrees with the implementation;
    # this is what the implementation actually required.
    def jobs(self,
        count : int = 0,
        itemdata : iter = None,
        clusterid : int = 1,
        procid : int = 0,
        qdate : int = 0,
        owner : str = "",
    ):
        """
        This function is not currently implemented.

        :param count:
        :param itemdata:
        :param clusterid:
        :param procid:
        :param qdate:
        :param owner:
        """
        raise NotImplementedError("Let us know what you need this for.")


    # In version 1, the documentation widly disagrees with the implementation;
    # this is what the implementation actually required.
    def procs(self,
        count : int = 0,
        itemdata : iter = None,
        clusterid : int = 1,
        procid : int = 0,
        qdate : int = 0,
        owner : str = "",
    ):
        """
        This function is not currently implemented.

        :param count:
        :param itemdata:
        :param clusterid:
        :param procid:
        :param qdate:
        :param owner:
        """
        raise NotImplementedError("Let us know what you need this for.")


    def getQArgs(self) -> str:
        """
        Return the arguments to the queue statement.
        """
        return _submit_getqargs(self, self._handle)


    def setQArgs(self, args : str):
        '''
        Set the arguments to the queue statement.  These arguments replace
        the arguments, if any, passed to the original constructor.

        :param args:  The arguments.  May start with "queue ".
        '''
        if not isinstance(args, str):
            raise TypeError("args must be a string")

        # We can always add a ValueError here if it's requested.
        if args.casefold().startswith("queue ".casefold()):
            args = args[6:]

        _submit_setqargs(self, self._handle, args)


    def itemdata(self, qargs : str = None) -> Union[ Iterator[str], Iterator[dict] ]:
        '''
        Returns an iterator over the itemdata specified by the given
        arguments to a queue statement,
        suitable for passing to :meth:`schedd.Submit`.

        ``s.itemdata()`` is equivalent to ``s.itemdata(s.getQAargs())``.

        :param qargs:  A set of arguments for a queue statement.  May
                       start with "queue ".
        '''
        s = self
        if qargs is not None:
            # It's legal for the qargs to reference submit macros,
            # so we have to build a duplicate of self.  We could do
            # this one the C++ side, instead, but we'd have to figure
            # out copying the SubmitBlob, instead.  (Or verify that
            # temporarily setting and resetting qargs doesn't change
            # anything, which seems tedious and unlikely.)
            s = Submit(str(self))
            s.setQArgs(qargs)
        (keys_str, values_str) = _submit_itemdata(s, s._handle)

        if values_str is None:
            return None
        elif keys_str is None:
            return iter(values_str.split("\n"))
        else:
            keys = keys_str.split("\n")
            values = values_str.split("\n")
            rv = []
            for value in values:
                if not value:
                    raise ValueError("invalid qargs")
                d = {}
                if "\x1F" in value:
                    v = value.split("\x1F")
                else:
                    v = [value]
                for i in range(0, len(keys)):
                    d[keys[i]] = v[i]
                rv.append(d)
            return iter(rv)


    def setSubmitMethod(self,
        method_value : Union[SubmitMethod, int] = -1,
        allow_reserved_values : bool = False
    ):
        """
        By default, jobs created by this class record (in the job ad
        attribute :ad-attr:`JobSubmitMethod`) that they were submitted via
        the  Python bindings.  Calling this method before submitting allows
        you to change that.  Values between ``0`` (inclusive) and ``100``
        (exclusive) are reserved for :class:`htcondor2.SubmitMethod`
        and can't be set unless the ``allowed_reserved_values`` flag is
        :const:`True`.  Values less than `0` may be used to remove
        ``JobSubmitMethod`` from the job ad entirely.

        :param method_value:  The method to record.
        :param allowed_reserved_values:  Set to true to used a reserved ``method_value``.
        """
        if isinstance(method_value, SubmitMethod):
            method_value = int(method_value)
        if 0 <= method_value and method_value < 100:
            if not allow_reserved_values:
                # This was HTCondorValueError in version 1.
                raise ValueError("Submit method value must be 100 or greater, or allowed_reserved_values must be True.")
        _submit_set_submit_method(self._handle, method_value)


    def getSubmitMethod(self) -> int:
        """
        Returns the submit method.

        :return:  The integer value of the submit method.  The symbolic
                  value can be obtained from the :class:`SubmitMethod`
                  enumeration.
        """
        return _submit_get_submit_method(self._handle)


    @staticmethod
    def from_dag(filename : str, options : Dict[str, Union[int, bool, str]] = {}) -> "Submit":
        """
        Returns a :class:`Submit` object that can be used to submit the
        DAG specified in the file `filename`.

        :param filename:  The DAG description file.
        :param options:  A dictionary of *condor_submit_dag* command-line
            options and their values.  See :meth:`from_dag_options`.
        """
        if not isinstance(options, dict):
            raise TypeError("options must be a dict")
        if not Path(filename).exists():
            raise HTCondorException(f"{filename} does not exist")

        # Convert from version 1 names to the proper internal names.
        internal_options = {}
        for key, value in options.items():
            if not isinstance(key, str):
                raise TypeError("options keys must by strings")
            elif len(key) == 0:
                raise ValueError("options key is empty")
            internal_key = _NewOptionNames.get(key.lower(), key)
            if not isinstance(value, (int, bool, str)):
                raise TypeError("options values must be int, bool, or str")
            internal_options[internal_key] = str(value)

        subfile = _submit_from_dag(filename, internal_options)
        subfile_text = Path(subfile).read_text()
        return Submit(subfile_text)


    def issue_credentials(self) -> Union[str, None]:
        '''
        Issue credentials for this job description.

        .. note::
            As with :tool:`condor_submit`, this assumes that the local
            machine is the target AP.

        :return:  A string containing a URL that the submitter must visit
                  in order to complete an OAuth2 flow, or :py:obj:`None`
                  if no such visit is necessary.
        '''
        return _submit_issue_credentials(self._handle)


    @staticmethod
    def from_dag_options():
        """
        Prints a table of valid keys for the ``options`` dictionary
        passed to :meth:`from_dag`, the type of each key, and the
        short description from *condor_submit_dag*'s ``-help`` output.

        This function is useful if you don't have an installed copy of
        :tool:`condor_submit_dag` or if this module and that command-line
        tool could be from different versions.

        .. note::

            Not all options available to *condor_submit_dag* exist for
            the python bindings.
        """
        _display_dag_options()


# List does not include options which vary only in capitalization.
_NewOptionNames = {
    "dagman":                   "DagmanPath",
    "schedd-daemon-ad-file":    "ScheddDaemonAdFile",
    "schedd-address-file":      "ScheddAddressFile",
    "alwaysrunpost":            "PostRun",
    "debug":                    "DebugLevel",
    "outfile_dir":              "OutfileDir",
    "config":                   "ConfigFile",
    "batch-name":               "BatchName",
    "load_save":                "SaveFile",
    "do_recurse":               "Recurse",
    "update_submit":            "UpdateSubmit",
    "import_env":               "ImportEnv",
    "include_env":              "GetFromEnv",
    "insert_env":               "AddToEnv",
    "dumprescue":               "DumpRescueDag",
    "valgrind":                 "RunValgrind",
    "suppress_notification":    "SuppressNotification",
    "dorecov":                  "DoRecovery",
}
