#!/usr/bin/python
# -*- coding: utf-8 -*-
"""Tanium SOUL package builder."""
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals

import argparse
import codecs
import datetime
import imp
import io
import logging
import os
import shutil
import sys

__version__ = "1.0.2"

THIS_FILE = os.path.abspath(__file__)
"""The absolute path to this file, ala ``/path/to/package/script.py``"""

THIS_SCRIPT = os.path.basename(THIS_FILE)
"""This scripts basename from THIS_FILE, ala ``script.py``"""

THIS_PATH = os.path.dirname(THIS_FILE)
"""The path of the directory containing THIS_FILE, ala ``/path/to/package/``"""

LOG = logging.getLogger(THIS_SCRIPT)


class TemplateProcessor(object):
    """Templatize the schema parsed from a WSDL file into an api.py."""

    INDENTS = {"i4": " " * 4, "i8": " " * 8, "i12": " " * 12}
    """Indentation variables used by self.tmpl."""

    SIMPLE_TYPES = ["int", "STR"]
    """Object types that represent 'simple' objects from the schema."""

    CR = "\n"
    """Carriage return constant."""

    CR1 = (CR * 1).join
    """Joiner method for joining lists with a single carriage return."""

    CR2 = (CR * 2).join
    """Joiner method for joining lists with two carriage returns."""

    DTF = "%Y-%m-%d at %H:%M:%S UTC"
    """Date time format."""

    def __init__(self, tmpl_path, tmpl_vars):
        """Constructor."""
        self.TMPL_PATH = tmpl_path
        self.TMPL_VARS = tmpl_vars
        self.TMPL_VARS.update(now=self.now)

        self.TMPLS = {}
        for k in os.listdir(tmpl_path):
            if (k.endswith("tmpl") or k.endswith("py")):
                self.TMPLS[k] = self.read_tmpl(name=k)

    def __str__(self):
        """Method for str() on self."""
        ret = "{c} using template path: {t}"
        ret = ret.format(c=self.__class__.__name__, t=self.TMPL_PATH)
        return ret

    def __repr__(self):
        """Method for repr() on self."""
        ret = self.__str__()
        return ret

    def api(self, schema, manual_wsdl):
        """Generate the text of all object classes."""
        self.USED_TMPLS = getattr(self, "USED_TMPLS", [])

        list_types = [x for x in schema if x["attrs"]["list_prop"]]
        item_types = [x for x in schema if not x["attrs"]["list_prop"]]
        manual_types = [x for x in manual_wsdl.MANUAL_WSDL]
        all_types = list_types + item_types + manual_types

        list_objs = [self.gen_obj(obj_dict=x, tmpl="wsdl_object") for x in list_types]
        item_objs = [self.gen_obj(obj_dict=x, tmpl="wsdl_object") for x in item_types]
        man_objs = [self.gen_obj(obj_dict=x, tmpl="manual_object") for x in manual_types]

        wsdl_maps = [{"prefix": k, "uri": v} for k, v in self.TMPL_VARS["wsdl_ns"].items()]
        wsdl_map = self.CR1([self.tmpl(tmpl="wsdl_map", subs=x) for x in wsdl_maps])

        obj_map = self.CR1([self.tmpl(tmpl="api_map", subs=x) for x in all_types])
        all_map = self.CR1([self.tmpl(tmpl="all_map", subs=x) for x in all_types])

        self.TMPL_VARS["api_map"] = obj_map
        self.TMPL_VARS["all_map"] = all_map
        self.TMPL_VARS["wsdl_map"] = wsdl_map

        statics = self.TMPLS["statics.py"]
        statics = "\n".join(statics.splitlines()[1:])
        self.USED_TMPLS.append("statics.py")

        header = self.tmpl(tmpl="header", subs=self.TMPL_VARS, strip=False)
        header += statics + "\n"
        footer = self.tmpl(tmpl="footer", subs=self.TMPL_VARS, strip=False)
        objs = self.CR2(man_objs + list_objs + item_objs)

        ret = self.CR2([header, objs, footer])

        for x in self.TMPLS:
            if x not in self.USED_TMPLS:
                m = "UNUSED TEMPLATE FILE: {x}"
                m = m.format(x=x)
                LOG.warning(m)
        return ret

    def gen_obj(self, obj_dict, tmpl):
        """Generate an object string from an obj_dict.

        obj_dict::

            {
                'attrs': {
                    'constants': [
                        {
                            'name': 'ADMIN',
                            'value': 'admin',
                            'value_type': u'STR'
                        },
                        ...
                    ],
                    'list_prop': {
                        'attr': { 'maxOccurs': 'unbounded', 'minOccurs': '0', 'name': 'query', 'type': 'sensor_query'},
                        'obj_name': u'Query',
                        'obj_type': u'SensorQuery',
                        'soap_name': 'query'
                    },
                    'properties': [
                        {
                            'attr': {'name': 'columns', 'type': 'plugin_sql_column_list'},
                            'obj_name': u'Columns',
                            'obj_type': u'PluginSqlColumnList',
                            'soap_name': 'columns',
                        },
                        ...
                    ]
                },
                'obj_name': u'SensorQueryList',
                'soap_name': 'sensor_query_list',
                'wsdl_doc': u''
            }
        """
        m = "Generating '{obj_name}' WSDL object for soap_name '{soap_name}'"
        m = m.format(**obj_dict)
        LOG.debug(m)

        attrs = obj_dict["attrs"]
        props = attrs["properties"]
        [self._update_prop(x) for x in props]

        lprop = attrs["list_prop"]
        self._update_prop(lprop) if lprop else None

        constants = attrs["constants"]

        cprops = self._sort_props([x for x in props if x["obj_type"] not in self.SIMPLE_TYPES])
        sprops = self._sort_props([x for x in props if x["obj_type"] in self.SIMPLE_TYPES])
        ignores = attrs.get("ignores", [])

        tv = {}
        tv.update(self.TMPL_VARS)
        tv.update(self.INDENTS)
        tv.update(obj_dict)

        tv["wsdl_doc"] = self.wsdl_doc(obj_dict=obj_dict)
        tv["attrs_doc"] = self.CR1(self.attrs_doc(sprops=sprops, cprops=cprops, lprop=lprop))
        tv["attrs"] = self.CR1(self.attrs_class(sprops=sprops, cprops=cprops, lprop=lprop))
        tv["api_dict"] = self.CR1(self.api_dict(sprops=sprops, cprops=cprops, lprop=lprop))
        tv["attrs_dict"] = self.CR1(self.attr_dict(sprops=sprops, cprops=cprops, lprop=lprop))

        if "class_type" not in tv:
            if lprop:
                tv["class_type"] = "ApiList"
            else:
                tv["class_type"] = "ApiObject"

        if "class_doc" not in tv:
            if lprop:
                tv["class_doc"] = self.tmpl(subs=tv, tmpl="list_doc")
            else:
                tv["class_doc"] = self.tmpl(subs=tv, tmpl="item_doc")

        if ignores:
            xx = self.CR1([self.tmpl(subs={"class_attr": x}, tmpl="simplex") for x in ignores])
            tv["api_ignore"] = xx
        else:
            tv["api_ignore"] = self.none_tmpl(subs=tv, t="ignored attributes", i=12)

        if constants:
            tv["constants"] = self.CR1(self.const_tmpl(constants=constants))
        else:
            tv["constants"] = self.none_tmpl(subs=tv, t="constants", i=8)

        if sprops:
            tv["simple"] = self.CR1([self.tmpl(subs=x, tmpl="simplex") for x in sprops])
        else:
            tv["simple"] = self.none_tmpl(subs=tv, t="simple attributes", i=12)

        if cprops:
            tv["complex"] = self.CR1([self.tmpl(subs=x, tmpl="simplex") for x in cprops])
        else:
            tv["complex"] = self.none_tmpl(subs=tv, t="complex attributes", i=12)

        ret = self.tmpl(subs=tv, tmpl=tmpl, strip=False)
        return ret

    def wsdl_doc(self, obj_dict):
        """Generate the text for the WSDL documentation."""
        lines = self.indent(obj_dict["wsdl_doc"], 8).replace("\\", "/").splitlines()
        ret = "\n".join(x.rstrip() for x in lines if x.strip())
        return ret

    def attr_dict(self, sprops=None, cprops=None, lprop=None):
        """Generate the text for the class ATTRS dict."""
        ret = []
        if lprop:
            ret.append(self.tmpl(subs=lprop, tmpl="attr_dict"))
        if sprops:
            ret += [self.tmpl(subs=x, tmpl="attr_dict") for x in sprops]
        if cprops:
            ret += [self.tmpl(subs=x, tmpl="attr_dict") for x in cprops]
        return ret

    def api_dict(self, sprops=None, cprops=None, lprop=None):
        """Generate the text for the class API dict."""
        ret = []
        """
        {
            "tag": "{soap_name}",
            "attr": "{class_attr}",
            "is_list": True,
            "is_api": {is_api},
            "type": {obj_type},
            "required": {required},
            "is_cdata": True/False,
        }
        """
        if lprop:
            ret.append(self._api_dict(lprop, True))
        if sprops:
            ret += [self._api_dict(x) for x in sprops]
        if cprops:
            ret += [self._api_dict(x) for x in cprops]
        return ret

    def _api_dict(self, x, is_list=False):
        items = [
            ['"tag"', '"{t}"'.format(t=x["soap_name"])],
            ['"attr"', '"{t}"'.format(t=x["class_attr"])],
            ['"type"', '{t}'.format(t=x["obj_type"])],
        ]

        if x["obj_type"] in self.SIMPLE_TYPES:
            items.append(['"is_api"', 'False'])
        else:
            items.append(['"is_api"', 'True'])

        if is_list:
            items.append(['"is_list"', 'True'])

        if x.get("is_cdata", False):
            items.append(['"is_cdata"', 'True'])

        if x.get("from_text", False):
            items.append(['"from_text"', 'True'])

        if x.get("from_attr", False):
            items.append(['"from_attr"', 'True'])

        if x.get("required", False):
            items.append(['"required"', 'True'])

        t_items = ["{i[0]}: {i[1]}".format(i=i) for i in items]
        x["api_dict"] = "{{{i}}}".format(i=", ".join(t_items))
        ret = self.tmpl(subs=x, tmpl="api_dict")
        return ret

    def _update_prop(self, x):
        """Add is_api, class_attr, and required to a property type."""
        # x["is_api"] = "False" if x["obj_type"] in self.SIMPLE_TYPES else "True"
        x["class_attr"] = x.get("class_attr", x["soap_name"])

    def _sort_props(self, props):
        """Sort properties in alpha sort, with id and name going first."""
        firsts = ["id", "name"]
        ret = sorted(props, key=lambda x: x["soap_name"])
        for x in firsts:
            for idx, i in enumerate(list(ret)):
                if i["soap_name"] == x:
                    ret.insert(0, ret.pop(idx))
        return ret

    def attrs_class(self, sprops=None, cprops=None, lprop=None):
        """Generate the text for the class attributes."""
        ret = []
        if lprop:
            ret.append(self.tmpl(subs=lprop, tmpl="attr_list"))
        if sprops:
            ret += [self.tmpl(subs=x, tmpl="attr_simplex") for x in sprops]
        if cprops:
            ret += [self.tmpl(subs=x, tmpl="attr_simplex") for x in cprops]
        return ret

    def attrs_doc(self, sprops=None, cprops=None, lprop=None):
        """Generate the text for the class attribute documentation."""
        ret = []
        if lprop:
            ret.append(self.tmpl(subs=lprop, tmpl="attr_doc_list"))
        if sprops:
            ret += [self.tmpl(subs=x, tmpl="attr_doc_simplex") for x in sprops]
        if cprops:
            ret += [self.tmpl(subs=x, tmpl="attr_doc_simplex") for x in cprops]
        return ret

    def const_tmpl(self, constants):
        """Generate the text for a list of constants."""
        ret = []
        for x in constants:
            if x["value_type"] == "int":
                x["value"] = int(x["value"])
            ret.append(self.tmpl(subs=x, tmpl="class_constant"))
        return ret

    def none_tmpl(self, t, subs, i):
        """Generate the text for an undefined type t with an indent of i."""
        ret = self.tmpl(subs=subs, tmpl="none_def", t=t, i=" " * i)
        return ret

    def tmpl(self, subs, tmpl, strip=True, **kwargs):
        


    def indent(self, text, lvl):
        """Indent text with space * lvl."""
        s = " " * lvl
        joiner = "\n{}".format(s).join
        ret = s + joiner(text.splitlines())
        return ret

    @property
    def now(self):
        """Get the time right now."""
        ret = datetime.datetime.utcnow().strftime(self.DTF)
        return ret

    def read_file(self, path, encoding="utf-8"):
        """Read path in as binary format, then convert to encoding using automatic BOM detector."""
        def bom_enc(raw, default="utf-8"):
            """Determine encoding of raw by checking for a BOM, defaulting to default if none found."""
            boms = (
                ("utf-8-sig", (codecs.BOM_UTF8,)),
                ("utf-16", (codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)),
                ("utf-32", (codecs.BOM_UTF32_LE, codecs.BOM_UTF32_BE)),
            )

            for enc, enc_boms in boms:
                if any(raw.startswith(bom) for bom in enc_boms):
                    return enc
            return default

        with io.open(path, "rb") as fh:
            raw = fh.read()

        enc = bom_enc(raw=raw, default=encoding)
        ret = codecs.decode(raw, enc)

        m = "Read '{p}' with {l} bytes as encoding '{e}'"
        m = m.format(p=path, l=len(ret), e=enc)
        LOG.debug(m)
        return ret

    def read_tmpl(self, name):
        """Read template name under path in as a unicode string."""
        tmpl_path = os.path.join(self.TMPL_PATH, name)
        ret = self.read_file(tmpl_path)
        return ret


def abspath(path):
    """Prepend path with THIS_PATH if it is not absolute."""
    if not os.path.isabs(path):
        path = os.path.join(THIS_PATH, path)
    return path


class ShellParser(argparse.ArgumentParser):
    """Wrapper for argparse."""

    def __init__(self, *args, **kwargs):
        """Constructor."""
        self.script = kwargs.pop('script', __name__)
        argparse.ArgumentParser.__init__(self, *args, **kwargs)

    def _print_message(self, message, file=None):
        if message:
            if file is None:
                file = sys.stdout
            file.write(message)

    def exit(self, status=0, message=None):
        """Exit handler."""
        if message:
            self._print_message(message, sys.stdout)
        os._exit(status)

    def error(self, message):
        """Error handler."""
        self.print_help()
        message = '\n!! Argument Parsing Error in "{}": {}\n'.format(self.script, message)
        self.exit(2, message)

    def print_help(self, **kwargs):
        """Help handler."""
        super(ShellParser, self).print_help(**kwargs)
        subs = [a for a in self._actions if isinstance(a, argparse._SubParsersAction)]
        for sub in subs:
            print("")
            for _, s in sub.choices.items():
                print(s.format_help())


def str2bool(value):
    """Argparse bool helper."""
    true_vals = ("yes", "y", "true", "t", "1")
    fail_vals = ("no", "n", "false", "f", "0")

    if value.lower() in true_vals:
        result = True
    elif value.lower() in fail_vals:
        result = False
    else:
        m = "{!r} not a valid choice, must be one of '{}' for true, or one of '{}' for false"
        m = m.format(value, ", ".join(true_vals), ", ".join(fail_vals))
        raise argparse.ArgumentTypeError(m)
    return result


def add_arg(obj, arg_name, arg_config):
    """Add argument named arg_name using arg_config to argparse obj."""
    default = arg_config.get("default", "")
    suppress = arg_config.get("suppress", False)
    req = arg_config.get("required", False)
    dest = arg_config.get("dest", arg_name)
    short = arg_config.get("short", True)
    helps = arg_config.get("help", "No help provided!")
    choices = arg_config.get("choices", None)

    long_opt = "--{}".format(arg_name)

    if short is True:
        short_opt = "-{}".format(arg_name[0])
    elif short:
        short_opt = "-{}".format(short)
    else:
        short_opt = ""

    if isinstance(default, bool):
        type_val = str2bool
    elif isinstance(default, int):
        type_val = int
    elif isinstance(default, float):
        type_val = float
    else:
        type_val = None

    defval = argparse.SUPPRESS if suppress else default
    ctxt = " [valid choices: {c}]".format(c=", ".join(choices)) if choices else ""
    rtxt = "[REQUIRED] " if req else ""
    helpstr = "{r}{h} [default: '{d}']{c}".format(h=helps, d=default, c=ctxt, r=rtxt)
    helpstr = helpstr.replace("%", "%%")

    add_args = [x for x in [long_opt, short_opt] if x]
    add_kwargs = dict(default=defval, required=req, dest=dest, type=type_val)
    add_kwargs.update(choices=choices, help=helpstr)

    obj.add_argument(*add_args, **add_kwargs)


def make_parser(script, doc, version, args):
    """Construct a ShellParser with args."""
    base = ShellParser(script=script, description=doc, add_help=False)

    for gname, gargs in args.items():
        grp = base.add_argument_group(gname)
        gargs_sort = sorted(gargs.items(), key=lambda x: x[1].get("priority", 1))
        for arg_name, arg_config in [x for x in gargs_sort if x[1].get("required", False)]:
            add_arg(obj=grp, arg_name=arg_name, arg_config=arg_config)
        for arg_name, arg_config in [x for x in gargs_sort if not x[1].get("required", False)]:
            add_arg(obj=grp, arg_name=arg_name, arg_config=arg_config)

    parser = ShellParser(script=script, description=doc, parents=[base], add_help=False)
    parser.add_argument('--version', action='version', version=version)
    parser.add_argument('--help', action='help')
    return parser


def run(args):
    """Run the script."""
    verbose = args.loud
    clean = args.clean
    platform_version = args.platform_version

    static_path = abspath(args.static_path)
    wsdl_path = abspath(args.wsdl_path)
    tmpl_path = abspath(args.tmpl_path)
    output_path = abspath(args.output_path)
    manual_wsdl_path = abspath(args.manual_wsdl)
    wsdl_file = os.path.basename(wsdl_path)
    api_file = "__init__.py"

    llvl = logging.DEBUG if verbose else logging.INFO
    lfmt = "[%(name)-12s] [%(funcName)-15s] %(levelname)-8s %(message)s"
    logging.basicConfig(level=llvl, format=lfmt)

    # by default, we try to parse platform_version from the filename

    if platform_version == "from_filename":
        try:
            platform_version = os.path.splitext(os.path.basename(args.wsdl_path))[0].split("-", 1)[1].strip()
        except Exception:
            m = "Unable to parse platform version from {p}, expected 'name-$version.wsdl'"
            m = m.format(p=args.wsdl_path)
            raise Exception(m)
        else:
            m = "Parsed platform version from {p} as '{v}'"
            m = m.format(p=args.wsdl_path, v=platform_version)
            LOG.info(m)

    # we use imp.load_source here to bootstrap parsers.py and version.py from static_path
    # because we don't have a "built" package of tanium_soul yet

    manual_wsdl = imp.load_source("wsdl_manual", manual_wsdl_path)

    parser_path = os.path.join(static_path, "parsers.py")
    parser = imp.load_source("tanium_soul.parsers", parser_path)

    pkg_version_path = os.path.join(static_path, "version.py")
    pkg_version = imp.load_source("pkg_version", pkg_version_path)

    # instantiate our wsdl parser
    wp = parser.WsdlParser()
    LOG.info(wp)

    # read wsdl_path in as bytes
    with io.open(wsdl_path, "rb") as fh:
        xml = fh.read()

    m = "Read wsdl_path '{p}' as bytes with {l} bytes"
    m = m.format(p=wsdl_path, l=len(xml))
    LOG.info(m)

    # parse xml for namespaces
    wsdl_ns = wp.xml2nsmap(xml=xml, register=True)

    m = "Parsed wsdl_path '{p}' for namespaces: {n}"
    m = m.format(p=wsdl_path, n=wsdl_ns)
    LOG.debug(m)

    # parse xml with comments into wrapped root
    root_el = wp.xml2el_comments(xml=xml, wrap_root=True)

    m = "Parsed wsdl_path '{p}' with comments into wrapped root {e}"
    m = m.format(p=wsdl_path, e=root_el)
    LOG.debug(m)

    # get version from comments in wrapped root
    wsdl_version = wp.get_wsdl_version(el=root_el)

    m = "Found wsdl_version '{v}' from comments under {e}"
    m = m.format(v=wsdl_version, e=root_el)
    LOG.debug(m)

    # get the schema element under the wrapped root using XPATH_TYPES from wp
    schema_el = wp.find(el=root_el, xpath=wp.XPATH_TYPES, nsmap=wsdl_ns)

    m = "Found schema with {l} items under root element using xpath '{x}'"
    m = m.format(l=len(schema_el), x=wp.XPATH_TYPES)
    LOG.info(m)

    # create a list of dicts using our schema
    schema = wp.schema(schema_el=schema_el, nsmap=wsdl_ns, logger=LOG)

    # create our baseline template variables
    tmpl_vars = {}
    tmpl_vars.update(pkg_version.VERSION)
    tmpl_vars.update(script_name=THIS_SCRIPT, script_ver=__version__, wsdl_ns=wsdl_ns)
    tmpl_vars.update(platform_version=platform_version, wsdl_file=wsdl_file, wsdl_version=wsdl_version)

    # instantiate our template processor
    tp = TemplateProcessor(tmpl_path=tmpl_path, tmpl_vars=tmpl_vars)
    LOG.info(tp)

    # parse schema into api
    api = tp.api(schema=schema, manual_wsdl=manual_wsdl)

    m = "Generated '{p}' with {l} bytes"
    m = m.format(p=api_file, l=len(api))
    LOG.info(m)

    # build $output_path/$pkg_dir based on pkg_title from VERSION in version.py
    pkg_dir = pkg_version.VERSION["pkg_title"]
    pkg_path = os.path.join(output_path, pkg_dir)

    if os.path.isdir(pkg_path):
        if not clean:
            m = "'{p}' already exists and clean is not True"
            m = m.format(p=pkg_path)
            raise Exception(m)
        else:
            m = "'{p}' already exists and clean is True, removing"
            m = m.format(p=pkg_path)
            LOG.info(m)
            shutil.rmtree(pkg_path, True)

    m = "Copying contents of '{s}' to '{p}'"
    m = m.format(s=static_path, p=pkg_path)
    LOG.info(m)

    shutil.copytree(static_path, pkg_path)
    shutil.copy(wsdl_path, pkg_path)
    api_path = os.path.join(pkg_path, api_file)

    with io.open(api_path, "w", encoding="utf-8") as fh:
        fh.write(api)

    m = "Wrote {l} bytes to {p}"
    m = m.format(l=len(api), p=api_path)
    LOG.info(m)

    m = "Finished building {p}"
    m = m.format(p=pkg_path)
    LOG.info(m)


if __name__ == "__main__":
    MANUAL_WSDL_FILE = "manual_wsdl.py"
    MANUAL_WSDL_PATH = os.path.join(THIS_PATH, MANUAL_WSDL_FILE)
    grp1 = {}
    grp1["clean"] = {"default": False, "help": "Remove pre-existing 'tanium_soul' in output_path"}
    grp1["output_path"] = {"default": THIS_PATH, "help": "Path to create 'tanium_soul' in"}
    grp1["static_path"] = {"default": "build_statics", "help": "Path containing static sources"}
    grp1["tmpl_path"] = {"default": "build_tmpls", "help": "Path containing templates"}
    grp1["loud"] = {"default": False, "help": "Increase logging verbosity"}
    grp1["wsdl_path"] = {"required": True, "help": "WSDL file to parse the schema from"}
    grp1["manual_wsdl"] = {"default": MANUAL_WSDL_PATH, "help": "Manual WSDL definitions file"}
    grp1["platform_version"] = {"default": "from_filename", "help": "Platform Version of wsdl_path"}

    args = {"Build tanium_soul Options": grp1}
    parser_args = {"script": THIS_SCRIPT, "doc": __doc__, "args": args, "version": __version__}

    parser = make_parser(**parser_args)
    args = parser.parse_args()
    run(args)

"""Generate the text for tmpl using subs and kwargs as substitutions."""
        tv = {}
        tv.update(self.INDENTS)
        tv.update(subs)
        tv.update(kwargs)
        real_tmpl_name = "{n}.tmpl".format(n=tmpl)
        tmpl = self.TMPLS[real_tmpl_name]
        tmpl = tmpl.strip() if strip else tmpl
        ret = tmpl.format(**tv)
        self.USED_TMPLS = getattr(self, "USED_TMPLS", [])
        if real_tmpl_name not in self.USED_TMPLS:
            self.USED_TMPLS.append(real_tmpl_name)
        return ret