import dis
import functools
import re
import types
from sys import version_info

import kajiki
from kajiki import i18n

from . import lnotab
from .html_utils import HTML_EMPTY_ATTRS
from .ir import generate_python
from .util import flattener, literal


class _obj(object):
    def __init__(self, **kw):
        for k, v in kw.items():
            setattr(self, k, v)


class _Template(object):
    """Base Class for all compiled Kajiki Templates.

    All kajiki templates created from a :class:`kajiki.ir.TemplateNode` will
    be subclasses of this class.

    As the template body code runs inside ``__main__`` method of this
    class, the instance of this class is always available as ``self``
    inside the template code.

    This class also makes available some global object inside the
    template code itself:

        - ``local`` which is the instance of the template
        - ``defined`` which checks if the given variable is defined
          inside the template scope.
        - ``Markup`` which marks the passed object as markup code and
          prevents escaping for its content.
        - ``__kj__`` which is a special object used by generated code
          providing features like keeping track of py:with stack or
          or the gettext function used to translate text.
    """

    __methods__ = ()
    loader = None
    base_globals = None
    filename = None

    def __init__(self, context=None):
        if context is None:
            context = {}
        self._context = context
        base_globals = self.base_globals or {}
        self.__globals__ = dict(
            local=self,
            self=self,
            defined=lambda x: x in self.__globals__,
            literal=literal,
            Markup=literal,
            gettext=i18n.gettext,
            __builtins__=__builtins__,
            __kj__=kajiki,
        )
        self.__globals__.update(base_globals)
        for k, v in self.__methods__:
            v = v.bind_instance(self)
            setattr(self, k, v)
            self.__globals__[k] = v
        self.__kj__ = _obj(
            extend=self._extend,
            push_switch=self._push_switch,
            pop_switch=self._pop_switch,
            case=self._case,
            import_=self._import,
            escape=self._escape,
            gettext=self._gettext,
            render_attrs=self._render_attrs,
            push_with=self._push_with,
            pop_with=self._pop_with,
            collect=self._collect,
        )
        self._switch_stack = []
        self._with_stack = []
        self.__globals__.update(context)
        self.__globals__["_"] = self.__globals__["gettext"]
        self.__globals__["value_of"] = self.__globals__.get

    def __iter__(self):
        """We convert the chunk to string because it can be of any type
        -- after all, the template supports expressions such as ${x+y}.
        Here, ``chunk`` can be the computed expression result.
        """
        for chunk in self.__main__():
            yield str(chunk)

    def render(self):
        """Render the template to a string."""
        return "".join(self)

    def _gettext(self, s):
        """Used by the code generated by the template to translate static text"""
        return self.__globals__["gettext"](s)

    def _push_with(self, locals_, vars):
        """Enter a ``py:with`` block.

        When a ``py:with`` block is encountered, previous values
        of the variables assigned inside the ``py:with`` statement are
        pushed on top of a stack by :class:`kajiki.ir.WithNode` so that
        when the node is exited the previous values can be recovered.
        """
        self._with_stack.append([locals_.get(k, ()) for k in vars])

    def _pop_with(self):
        """Exists a ``py:with`` block.

        When a ``py:with`` block is exited the values stack is popped
        and the head returned to :class:`kajiki.ir.WithNode` so that
        it can set any previously existing variable to its old value.
        """
        return self._with_stack.pop()

    def _extend(self, parent):
        """
        Called when a child template extends a parent template
        the first thing it does is asking the loader of the
        child template to load the parent template
        """
        if isinstance(parent, str):
            parent = self.loader.import_(parent)
        p_inst = parent(self._context)
        p_globals = p_inst.__globals__
        # Find overrides
        for k, v in self.__globals__.items():
            if k == "__main__":
                continue
            if not isinstance(v, TplFunc):
                continue
            p_globals[k] = v
        # Find inherited funcs
        for k, v in p_inst.__globals__.items():
            if k == "__main__":
                continue
            if not isinstance(v, TplFunc):
                continue
            if k not in self.__globals__:
                self.__globals__[k] = v
            if not hasattr(self, k):

                def _(k=k):
                    """Capture the 'k' variable in a closure"""

                    def trampoline(*a, **kw):
                        global parent
                        return getattr(parent, k)(*a, **kw)

                    return trampoline

                setattr(self, k, TplFunc(_()).bind_instance(self))
        p_globals["child"] = self
        p_globals["local"] = p_inst
        p_globals["self"] = self.__globals__["self"]
        self.__globals__["parent"] = p_inst
        self.__globals__["local"] = self
        return p_inst

    def _push_switch(self, expr):
        """Enter a ``py:switch`` block.

        Pushes provided value on the stack used
        to check ``py:case`` statements against.

        Calling :meth:`._pop_switch` will exit the switch block.
        """
        self._switch_stack.append(expr)

    def _pop_switch(self):
        """Exit current ``py:switch`` block.

        Pops current value from the stack used
        to check ``py:case`` statements against.
        """
        self._switch_stack.pop()

    def _case(self, obj):
        """Check against current ``py:switch`` value."""
        return obj == self._switch_stack[-1]

    def _import(self, name, alias, gbls):
        # Load template as a fragment to avoid extra <DOCTYPE> in included output.
        # Due to loader cache, this has the side effect that if the same
        # template is both included and used as a standalone page
        # it might act as a fragment or not depending on the order it was loaded.
        # But usually templates meant for inclusion are not standalone pages.
        # Also there is no way to set a template as a fragment once loaded.
        # So we can only do it through the loader.
        tpl_cls = self.loader.import_(name, is_fragment=True)
        if alias is None:
            alias = self.loader.default_alias_for(name)
        r = gbls[alias] = tpl_cls(gbls)
        return r

    def _escape(self, value):
        """Returns the given HTML with ampersands, carets and quotes encoded."""
        if value is None or isinstance(value, flattener):
            return value
        if hasattr(value, "__html__"):
            return value.__html__()
        uval = str(value)
        if self._re_escape.search(uval):  # Scan the string before working.
            # stdlib escape() is inconsistent between Python 2 and Python 3.
            # In 3, html.escape() translates the single quote to '&#39;'
            # In 2.6 and 2.7, cgi.escape() does not touch the single quote.
            # Preserve our tests and Kajiki behaviour across Python versions:
            return (
                uval.replace("&", "&amp;")
                .replace("<", "&lt;")
                .replace(">", "&gt;")
                .replace('"', "&quot;")
            )
            # .replace("'", '&#39;'))
            # Above we do NOT escape the single quote; we don't need it because
            # all HTML attributes are double-quoted in our output.
        else:
            return uval

    _re_escape = re.compile(r'&|<|>|"')

    def _render_attrs(self, attrs, mode):
        """Render tag attributes in key="value" format.

        A :class:`kajiki.ir.AttrsNode` will generate
        code that in fact leads to this function to generate
        the html for tag attributes.
        """
        if hasattr(attrs, "items"):
            attrs = attrs.items()
        if attrs is not None:
            for k, v in sorted(attrs):
                if k in HTML_EMPTY_ATTRS and v in (True, False):
                    v = k if v else None
                if v is None:
                    continue
                if mode.startswith("html") and k in HTML_EMPTY_ATTRS:
                    yield " " + k.lower()
                else:
                    yield ' %s="%s"' % (k, self._escape(v))

    def _collect(self, it):
        result = []
        for part in it:
            if part is None:
                continue
            if isinstance(part, flattener):
                result.append(str(part.accumulate_str()))
            else:
                result.append(str(part))
        if result:
            return "".join(result)
        else:
            return None

    @classmethod
    def annotate_lnotab(cls, py_to_tpl):
        for name, meth in cls.__methods__:
            meth.annotate_lnotab(cls.filename, py_to_tpl, dict(py_to_tpl))

    def defined(self, name):
        """Check if a variable was provided to the template or not"""
        return name in self._context


def Template(ns):
    """Creates a new :class:`._Template` subclass from an entity with ``exposed`` functions.

    Kajiki uses classes as containers of the exposed functions for convenience,
    but any object that can have the functions as attributes works.

    To be a valid template the original entity must provide at least a ``__main__``
    function::

        class Example:
            @kajiki.expose
            def __main__():
                yield 'Hi'

        t = kajiki.Template(Example)
        output = t().render()

        print(output)
        'Hi'
    """
    dct = {}
    methods = dct["__methods__"] = []
    for name in dir(ns):
        value = getattr(ns, name)
        if getattr(value, "exposed", False):
            methods.append((name, TplFunc(getattr(value, "__func__", value))))
    return type(ns.__name__, (_Template,), dct)


def from_ir(ir_node, base_globals=None):
    """Creates a template class from Intermediate Representation TemplateNode.

    This actually creates the class defined by the TemplateNode by executing
    its code and returns a subclass of it.
    The returned class is a subclass of :class:`kajiki.template._Template`.

    It is possible to use `base_globals` to set context values
    or replace default ones
    """
    if base_globals is None:
        base_globals = dict()
    py_lines = list(generate_python(ir_node))
    py_text = "\n".join(map(str, py_lines))
    py_linenos = []
    last_lineno = 0
    py_lineno = 1
    for line in py_lines:
        lno = max(last_lineno, line._lineno or 0)
        for _ in range(str(line).count("\n") + 1):
            py_linenos.append((py_lineno, lno))
            py_lineno += 1
        last_lineno = lno
    dct = dict(kajiki=kajiki)
    try:
        exec(py_text, dct)
    except (SyntaxError, IndentationError) as e:  # pragma no cover
        raise KajikiSyntaxError(e.msg, py_text, e.filename, e.lineno, e.offset)
    tpl = dct["template"]
    tpl.base_globals = base_globals.copy()
    tpl.base_globals.update(dct)
    tpl.py_text = py_text
    tpl.filename = ir_node.filename
    tpl.annotate_lnotab(py_linenos)
    return tpl


class TplFunc(object):
    """A template function attached to a _Template.

    By default template functions (ie: __main__) depends
    on variables like ``self``, ``local`` and so on which
    are provided by :class:`._Template`.

    This is used by :meth:`.Template` to create a new
    ``_Template`` with the attached functions.
    """

    def __init__(self, func, inst=None):
        self._func = func
        self._inst = inst
        self._bound_func = None

    def bind_instance(self, inst):
        return TplFunc(self._func, inst)

    def __repr__(self):  # pragma no cover
        if self._inst:
            return "<bound tpl_function %r of %r>" % (self._func.__name__, self._inst)
        else:
            return "<unbound tpl_function %r>" % (self._func.__name__)

    def __call__(self, *args, **kwargs):
        if self._bound_func is None:
            self._bound_func = self._bind_globals(self._inst.__globals__)
        return self._bound_func(*args, **kwargs)

    def _bind_globals(self, globals):
        """Return a function which has the globals dict set to 'globals'
        and which flattens the result of self._func'.
        """
        func = types.FunctionType(
            self._func.__code__,
            globals,
            self._func.__name__,
            self._func.__defaults__,
            self._func.__closure__,
        )
        return functools.update_wrapper(
            lambda *a, **kw: flattener(func(*a, **kw)), func
        )

    def annotate_lnotab(self, filename, py_to_tpl, py_to_tpl_dct):
        if not py_to_tpl:
            return
        code = self._func.__code__
        new_lnotab_numbers = []
        for bc_off, py_lno in dis.findlinestarts(code):
            tpl_lno = py_to_tpl_dct[py_lno]
            new_lnotab_numbers.append((bc_off, tpl_lno))
        if not new_lnotab_numbers:
            return
        new_firstlineno = py_to_tpl_dct.get(code.co_firstlineno, 0)
        new_lnotab = lnotab.lnotab_string(new_lnotab_numbers, new_firstlineno)
        new_code = patch_code_file_lines(code, filename, new_firstlineno, new_lnotab)
        self._func.__code__ = new_code
        return


def patch_code_file_lines(code, filename, firstlineno, lnotab):
    code_args = [code.co_argcount]
    if version_info >= (3, 8):
        code_args.append(code.co_posonlyargcount)
    code_args.extend(
        [
            code.co_kwonlyargcount,
            code.co_nlocals,
            code.co_stacksize,
            code.co_flags,
            code.co_code,
            code.co_consts,
            code.co_names,
            code.co_varnames,
            filename,
            code.co_name,
            firstlineno,
            lnotab,
            code.co_freevars,
            code.co_cellvars,
        ]
    )
    return types.CodeType(*code_args)


class KajikiTemplateError(Exception):
    def __init__(self, msg, source, filename, linen, coln):
        super().__init__(
            "[%s:%s] %s\n%s"
            % (filename, linen, msg, self._get_source_snippet(source, linen))
        )
        self.filename = filename
        self.linenum = linen
        self.colnum = coln

    def _get_source_snippet(self, source, lineno):
        lines = source.splitlines()

        # Lines are 1 indexed, account for that.
        lineno -= 1

        parts = []
        for i in range(lineno - 2, lineno + 2):
            if 0 <= i < len(lines):
                parts.append(
                    "\t {arrow} {src}\n".format(
                        arrow="-->" if i == lineno else "   ", src=lines[i]
                    )
                )
        return "".join(parts)


class KajikiSyntaxError(KajikiTemplateError):
    pass
