import os
import re
import textwrap
import time
import xml.sax.saxutils

from gi.repository import GLib, Pango

from gourmand import convert
from gourmand.gdebug import TimeAction, debug, print_timer_info
from gourmand.gglobals import DEFAULT_ATTR_ORDER, DEFAULT_TEXT_ATTR_ORDER, REC_ATTR_DIC, TEXT_ATTR_DIC, use_threads
from gourmand.i18n import _
from gourmand.plugin import BaseExporterMultiRecPlugin, BaseExporterPlugin
from gourmand.plugin_loader import Pluggable, pluggable_method
from gourmand.threadManager import SuspendableThread


class exporter(SuspendableThread, Pluggable):
    """A base exporter class.

    All Gourmet exporters should subclass this class or one of its
    derivatives.

    This class can also be used directly for plain text export.
    """

    DEFAULT_ENCODING = "utf-8"

    name = "exporter"
    ALLOW_PLUGINS_TO_WRITE_NEW_FIELDS = True

    def __init__(
        self,
        rd,
        r,
        out,
        conv=None,
        imgcount=1,
        order=["image", "attr", "ings", "text"],
        attr_order=DEFAULT_ATTR_ORDER,
        text_attr_order=DEFAULT_TEXT_ATTR_ORDER,
        do_markup=True,
        use_ml=False,
        convert_attnames=True,
        fractions=convert.FRACTIONS_ASCII,
    ):
        """Instantiate our exporter.

        conv is a preexisting convert.converter() class
        imgcount is a number we use to start counting our exported images.
        order is a list of our core elements in order: 'image','attr','text' and 'ings'
        attr_order is a list of our attributes in the order we should export them:
                   title, category, cuisine, servings, source, rating, preptime, cooktime
        text_attr_order is a list of our text attributes.
        do_markup is a flag; if true, we interpret tags in text blocks by calling
                  self.handle_markup to e.g. to simple plaintext renderings of tags.
        use_ml is a flag; if true, we escape strings we output to be valid *ml
        convert_attnames is a flag; if true, we hand write_attr a translated attribute name
                         suitable for printing or display. If not, we just hand it the standard
                         attribute name (this is a good idea if a subclass needs to rely on the
                         attribute name staying consistent for processing, since converting attnames
                         will produce locale specific strings.
        """
        self.attr_order = attr_order
        self.text_attr_order = text_attr_order
        self.out = out
        self.r = r
        self.rd = rd
        self.do_markup = do_markup
        self.fractions = fractions
        self.use_ml = use_ml
        self.convert_attnames = convert_attnames
        if not conv:
            conv = convert.get_converter()
        self.conv = conv
        self.imgcount = imgcount
        self.images = []
        self.order = order
        Pluggable.__init__(self, [BaseExporterPlugin])
        SuspendableThread.__init__(self, self.name)

    def do_run(self):
        self.write_head()
        for task in self.order:
            if task == "image":
                if self._grab_attr_(self.r, "image"):
                    self.write_image(self.r.image)
            if task == "attr":
                self._write_attrs_()

            elif task == "text":
                self._write_text_()
            elif task == "ings":
                self._write_ings_()
        self.write_foot()

    # Internal methods -- ideally, subclasses should have no reason to
    # override any of these methods.
    @pluggable_method
    def _write_attrs_(self):
        self.write_attr_head()
        for a in self.attr_order:
            txt = self._grab_attr_(self.r, a)
            debug("_write_attrs_ writing %s=%s" % (a, txt), 1)
            if txt and ((not isinstance(txt, str)) or txt.strip()):
                if (a == "preptime" or a == "cooktime") and a.find("0 ") == 0:
                    pass
                else:
                    if self.convert_attnames:
                        self.write_attr(REC_ATTR_DIC.get(a, a), txt)
                    else:
                        self.write_attr(a, txt)
        self.write_attr_foot()

    @pluggable_method
    def _write_text_(self):
        # print 'exporter._write_text_',self.text_attr_order,'!'
        for a in self.text_attr_order:
            # This code will never be called for Gourmet
            # proper... here for convenience of symbiotic project...
            if a == "step":
                steps = self._grab_attr_(self.r, a)
                if not steps:
                    continue
                for s in steps:
                    if isinstance(s, dict):
                        dct = s
                        s = dct.get("text", "")
                        img = dct.get("image", "")
                        time = dct.get("time", 0)
                        # print 'Exporter sees step AS:'
                        # print '  text:',s
                        # print '  image:',img
                        # print '  time:',time
                    else:
                        img = ""
                    if self.do_markup:
                        txt = self.handle_markup(s)
                    if not self.use_ml:
                        txt = xml.sax.saxutils.unescape(s)
                    # if self.convert_attnames:
                    #     out_a = TEXT_ATTR_DIC.get(a, a)
                    # else:
                    #     out_a = a

                    # Goodness this is an ugly way to pass the
                    # time as a parameter... we use try/except to
                    # allow all gourmet exporters to ignore this
                    # attribute.
                    try:
                        self.write_text(a, s, time=time)
                    except:
                        self.write_text(a, s)
                        print("Failed to export time=", time)
                        raise
                    if img:
                        self.write_image(img)
                continue
            # End of non-Gourmet code
            txt = self._grab_attr_(self.r, a)
            if txt and txt.strip():
                if self.do_markup:
                    txt = self.handle_markup(txt)
                # else: print 'exporter: do_markup=False'
                if not self.use_ml:
                    txt = xml.sax.saxutils.unescape(txt)
                if self.convert_attnames:
                    self.write_text(TEXT_ATTR_DIC.get(a, a), txt)
                else:
                    self.write_text(a, txt)

    @pluggable_method
    def _write_ings_(self):
        """Write all of our ingredients."""
        ingredients = self.rd.get_ings(self.r)
        if not ingredients:
            return
        self.write_inghead()
        for g, ings in self.rd.order_ings(ingredients):
            if g:
                self.write_grouphead(g)
            for i in ings:
                amount, unit = self._get_amount_and_unit_(i)
                if self._grab_attr_(i, "refid"):
                    self.write_ingref(
                        amount=amount, unit=unit, item=self._grab_attr_(i, "item"), refid=self._grab_attr_(i, "refid"), optional=self._grab_attr_(i, "optional")
                    )
                else:
                    self.write_ing(
                        amount=amount, unit=unit, item=self._grab_attr_(i, "item"), key=self._grab_attr_(i, "ingkey"), optional=self._grab_attr_(i, "optional")
                    )
            if g:
                self.write_groupfoot()
        self.write_ingfoot()

    def _grab_attr_(self, obj, attr):
        # This is a bit ugly -- we allow exporting categories as if
        # they were a single attribute even though we in fact allow
        # multiple categories.
        if attr == "category":
            return ", ".join(self.rd.get_cats(obj))
        try:
            ret = getattr(obj, attr)
        except AttributeError:
            return None
        else:
            if attr in ["preptime", "cooktime"]:
                # this 'if' ought to be unnecessary, but is kept around
                # for db converting purposes -- e.g. so we can properly
                # export an old DB
                if ret and not isinstance(ret, str):
                    ret = convert.seconds_to_timestring(ret, fractions=self.fractions)
            elif attr == "rating" and ret and not isinstance(ret, str):
                if ret / 2 == ret / 2.0:
                    ret = "%s/5 %s" % (ret / 2, _("stars"))
                else:
                    ret = "%s/5 %s" % (ret / 2.0, _("stars"))
            elif attr == "servings" and not isinstance(ret, str):
                ret = convert.float_to_frac(ret, fractions=self.fractions)
            elif attr == "yields":
                ret = convert.float_to_frac(ret, fractions=self.fractions)
                yield_unit = self._grab_attr_(obj, "yield_unit")
                if yield_unit:
                    ret = "%s %s" % (ret, yield_unit)  # FIXME: i18n? (fix also below in exporter_mult)

            return ret

    def _get_amount_and_unit_(self, ing):
        return self.rd.get_amount_and_unit(ing, fractions=self.fractions)

    # Below are the images inherited exporters should
    # subclass. Subclasses overriding methods should make these
    # pluggable so that plugins can fiddle about with things as they
    # see fit.

    def write_image(self, image):
        """Write image based on binary data for an image file (jpeg format)."""
        pass

    def write_head(self):
        """Write any necessary header material at recipe start."""
        pass

    def write_foot(self):
        """Write any necessary footer material at recipe end."""
        pass

    @pluggable_method
    def write_inghead(self):
        """Write any necessary markup before ingredients."""
        self.out.write("\n---\n%s\n---\n" % _("Ingredients"))

    @pluggable_method
    def write_ingfoot(self):
        """Write any necessary markup after ingredients."""
        pass

    @pluggable_method
    def write_attr_head(self):
        """Write any necessary markup before attributes."""
        pass

    @pluggable_method
    def write_attr_foot(self):
        """Write any necessary markup after attributes."""
        pass

    @pluggable_method
    def write_attr(self, label, text):
        """Write an attribute with label and text.

        If we've been initialized with convert_attnames=True, the
        label will already be translated to our current
        locale. Otherwise, the label will be the same as it used
        internally in our database.

        So if your method needs to do something special based on the
        attribute name, we need to set convert_attnames to False (and
        do any necessary i18n of the label name ourselves.
        """
        self.out.write("%s: %s\n" % (label, text.strip()))

    @pluggable_method
    def write_text(self, label, text):
        """Write a text chunk.

        This could include markup if we've been initialized with
        do_markup=False.  Otherwise, markup will be handled by the
        handle_markup methods (handle_italic, handle_bold,
        handle_underline).
        """
        self.out.write("\n---\n%s\n---\n" % label)
        ll = text.split("\n")
        for line in ll:
            for wrapped_line in textwrap.wrap(line):
                self.out.write("\n%s" % wrapped_line)
        self.out.write("\n\n")

    def handle_markup(self, txt):
        """Handle markup inside of txt."""
        if txt is None:
            print("Warning, handle_markup handed None")
            return ""
        outtxt = ""
        try:
            ok, al, txt, sep = Pango.parse_markup(txt, -1, "\x00")
        except GLib.Error:
            txt = xml.sax.saxutils.escape(txt)
            ok, al, txt, sep = Pango.parse_markup(txt, -1, "\x00")
        ai = al.get_iterator()
        # The AttrIterator describes ranges of bytes in the UTF-8 representation
        b = txt.encode("utf-8")
        more = True
        while more:
            fd = Pango.FontDescription()
            ai.get_font(fd)
            start, end = ai.range()
            # The range should never split up a code point in the UTF-8
            chunk = xml.sax.saxutils.escape(b[start:end].decode("utf-8"))
            trailing_newline = ""
            fields = fd.get_set_fields()
            if fields != 0:  # if there are fields
                # Sometimes we get trailing newlines, which is ugly
                # because we end up with e.g. <b>Foo\n</b>
                #
                # So we define trailing_newline as a variable
                if chunk and chunk[-1] == "\n":
                    trailing_newline = "\n"
                    chunk = chunk[:-1]
                if "style" in fields.value_nicks and fd.get_style() == Pango.Style.ITALIC:
                    chunk = self.handle_italic(chunk)
                if "weight" in fields.value_nicks and fd.get_weight() == Pango.Weight.BOLD:
                    chunk = self.handle_bold(chunk)

            for att in ai.get_attrs():
                # FIXME: Pango attribute values are not introspectable from Python
                #   https://gitlab.gnome.org/GNOME/pango/-/issues/476
                # For now, assume that any underline attribute is single-underline
                if att.klass.type == Pango.AttrType.UNDERLINE:  # and att.value==Pango.Underline.SINGLE:
                    chunk = self.handle_underline(chunk)
            outtxt += chunk + trailing_newline
            more = ai.next()
        return outtxt

    def handle_italic(self, chunk):
        """Make chunk italic, or the equivalent."""
        return "*" + chunk + "*"

    def handle_bold(self, chunk):
        """Make chunk bold, or the equivalent."""
        return chunk.upper()

    def handle_underline(self, chunk):
        """Make chunk underlined, or the equivalent of"""
        return "_" + chunk + "_"

    @pluggable_method
    def write_grouphead(self, text):
        """The start of group of ingredients named TEXT"""
        self.out.write("\n%s:\n" % text.strip())

    @pluggable_method
    def write_groupfoot(self):
        """Mark the end of a group of ingredients."""
        pass

    @pluggable_method
    def write_ingref(self, amount=1, unit=None, item=None, optional=False, refid=None):
        """By default, we don't handle ingredients as recipes, but
        someone subclassing us may wish to do so..."""
        self.write_ing(amount=amount, unit=unit, item=item, key=None, optional=optional)

    @pluggable_method
    def write_ing(self, amount=1, unit=None, item=None, key=None, optional=False):
        """Write ingredient."""
        if amount:
            self.out.write("%s" % amount)
        if unit:
            self.out.write(" %s" % unit)
        if item:
            self.out.write(" %s" % item)
        if optional:
            self.out.write(" (%s)" % _("optional"))
        self.out.write("\n")


class exporter_mult(exporter):
    """A basic exporter class that can handle a multiplied recipe."""

    def __init__(
        self,
        rd,
        r,
        out,
        conv=None,
        change_units=True,
        mult=1,
        imgcount=1,
        order=["image", "attr", "ings", "text"],
        attr_order=DEFAULT_ATTR_ORDER,
        text_attr_order=DEFAULT_TEXT_ATTR_ORDER,
        do_markup=True,
        use_ml=False,
        convert_attnames=True,
        fractions=convert.FRACTIONS_ASCII,
    ):
        """Initiate an exporter class capable of multiplying the recipe.

        We allow the same arguments as the base exporter class plus
        the following

        mult = number (multiply by this number)

        change_units = True|False (whether to change units to keep
        them readable when multiplying).
        """
        self.mult = mult
        self.change_units = change_units
        exporter.__init__(
            self,
            rd,
            r,
            out,
            conv,
            imgcount,
            order,
            attr_order=attr_order,
            text_attr_order=text_attr_order,
            use_ml=use_ml,
            do_markup=do_markup,
            convert_attnames=convert_attnames,
            fractions=fractions,
        )

    @pluggable_method
    def write_attr(self, label, text):
        # attr = NAME_TO_ATTR[label]
        self.out.write("%s: %s\n" % (label, text))

    def _grab_attr_(self, obj, attr):
        """Grab attribute attr of obj obj.

        Possibly manipulate the attribute we get to hand out export
        something readable.
        """
        if attr == "servings" or attr == "yields" and self.mult:
            ret = getattr(obj, attr)
            if isinstance(ret, (int, float)):
                fl_ret = float(ret)
            else:
                if ret is not None:
                    print("WARNING: IGNORING serving value ", ret)
                fl_ret = None
            if fl_ret:
                ret = convert.float_to_frac(fl_ret * self.mult, fractions=self.fractions)
                if attr == "yields":
                    yield_unit = self._grab_attr_(obj, "yield_unit")
                    if yield_unit:
                        ret = "%s %s" % (ret, yield_unit)  # FIXME: i18n?
                return ret
        else:
            return exporter._grab_attr_(self, obj, attr)

    def _get_amount_and_unit_(self, ing):
        if self.mult != 1 and self.change_units:
            return self.rd.get_amount_and_unit(ing, mult=self.mult, conv=self.conv, fractions=self.fractions)
        else:
            return self.rd.get_amount_and_unit(ing, mult=self.mult, conv=self.conv, fractions=self.fractions)

    @pluggable_method
    def write_ing(self, amount=1, unit=None, item=None, key=None, optional=False):
        if amount:
            self.out.write("%s" % amount)
        if unit:
            self.out.write(" %s" % unit)
        if item:
            self.out.write(" %s" % item)
        if optional:
            self.out.write(" (%s)" % _("optional"))
        self.out.write("\n")


class ExporterMultirec(SuspendableThread, Pluggable):

    name = "Exporter"

    def __init__(
        self, rd, recipes, out, one_file=True, create_file=True, ext="txt", conv=None, imgcount=1, exporter=exporter, exporter_kwargs={}, padding=None
    ):
        """Output all recipes in recipes into a document or multiple
        documents. if one_file, then everything is in one
        file. Otherwise, we treat 'out' as a directory and put
        individual recipe files within it.

        @param out: The name of the output file or directory

        @param one_file: If True works in one_file mode. For this the class will
                    create the file which is accessible by self.ofi
                    If this is set to False a directory will be created and the
                    name of the directory will be passed via self.outdir and self.ofi

        @param create_file: If this parameter is True the files will be created
                    otherwise to create the file is up to the user.
        """
        self.timer = TimeAction("exporterMultirec.__init__()")
        self.rd = rd
        self.recipes = recipes
        self.out = out
        self.outdir = out
        self.padding = padding
        self.one_file = one_file
        Pluggable.__init__(self, [BaseExporterMultiRecPlugin])
        SuspendableThread.__init__(self, self.name)
        self.ext = ext
        self.exporter = exporter
        self.exporter_kwargs = exporter_kwargs
        self.fractions = self.exporter_kwargs.get("fractions", convert.FRACTIONS_ASCII)
        self.DEFAULT_ENCODING = self.exporter.DEFAULT_ENCODING
        self.one_file = one_file
        self.create_file = create_file

    def _grab_attr_(self, obj, attr):
        if attr == "category":
            return ", ".join(self.rd.get_cats(obj))
        try:
            ret = getattr(obj, attr)
        except AttributeError:
            return None
        else:
            if attr in ["preptime", "cooktime"]:
                # this 'if' ought to be unnecessary, but is kept around
                # for db converting purposes -- e.g. so we can properly
                # export an old DB
                if ret and not isinstance(ret, str):
                    ret = convert.seconds_to_timestring(ret, fractions=self.fractions)
            elif attr == "rating" and ret and not isinstance(ret, str):
                if ret / 2 == ret / 2.0:
                    ret = "%s/5 %s" % (ret / 2, _("stars"))
                else:
                    ret = "%s/5 %s" % (ret / 2.0, _("stars"))

            return ret

    def append_referenced_recipes(self):
        for r in self.recipes[:]:
            reffed = self.rd.db.execute("select * from ingredients where recipe_id=%s and refid is not null" % r.id)
            for ref in reffed:
                rec = self.rd.get_rec(ref.refid)
                if rec is not None and rec not in self.recipes:
                    self.recipes.append(rec)

    @pluggable_method
    def do_run(self):
        self.rcount = 0
        self.rlen = len(self.recipes)
        if not self.one_file:
            self.outdir = self.out
            if os.path.exists(self.outdir):
                if not os.path.isdir(self.outdir):
                    self.outdir = self.unique_name(self.outdir)
                    os.makedirs(self.outdir)
            else:
                os.makedirs(self.outdir)
        create_one_file = self.one_file and isinstance(self.out, str) and self.create_file
        create_multi_file = not self.one_file and isinstance(self.out, str)
        if create_one_file:
            self.ofi = open(self.out, "w", encoding=self.DEFAULT_ENCODING)
        else:
            self.ofi = self.out
        self.write_header()
        self.suspended = False
        self.terminated = False
        first = True
        self.append_referenced_recipes()
        for r in self.recipes:
            self.check_for_sleep()
            msg = _("Exported %(number)s of %(total)s recipes") % {"number": self.rcount, "total": self.rlen}
            self.emit("progress", float(self.rcount) / float(self.rlen), msg)
            fn = None
            if create_multi_file:
                fn = self.generate_filename(r, self.ext, add_id=True)
                self.ofi = open(fn, "w", encoding=self.DEFAULT_ENCODING)
            if self.padding and not first:
                self.ofi.write(self.padding)
            e = self.exporter(out=self.ofi, r=r, rd=self.rd, **self.exporter_kwargs)
            self.connect_subthread(e)
            e.do_run()
            self.recipe_hook(r, fn, e)
            if create_multi_file:
                self.ofi.close()
            self.rcount += 1
            first = False
        self.write_footer()
        if create_one_file:
            self.ofi.close()
        self.timer.end()
        self.emit("progress", 1, _("Export complete."))
        print_timer_info()

    @pluggable_method
    def write_header(self):
        pass

    @pluggable_method
    def write_footer(self):
        pass

    def generate_filename(self, rec, ext, add_id=False):
        title = rec.title
        # get rid of potentially confusing characters in the filename
        # Windows doesn't like a number of special characters, so for
        # the time being, we'll just get rid of all non alpha-numeric
        # characters
        ntitle = ""
        for c in title:
            if re.match("[A-Za-z0-9 ]", c):
                ntitle += c
        title = ntitle
        # truncate long filenames
        max_filename_length = 252
        if len(title) > (max_filename_length - 4):
            title = title[0 : max_filename_length - 4]
        # make sure there is a title
        if not title:
            title = _("Recipe")
        title = title.replace("/", " ")
        title = title.replace("\\", " ")
        # Add ID #
        if add_id:
            title = title + str(rec.id)
        file_w_ext = "%s%s%s" % (self.unique_name(title), os.path.extsep, ext)
        return os.path.join(self.outdir, file_w_ext)

    def recipe_hook(self, rec, filename=None, exporter=None):
        """Intended to be subclassed by functions that want a chance
        to act on each recipe, possibly knowing the name of the file
        the rec is going to. This makes it trivial, for example, to build
        an index (written to a file specified in write_header."""
        pass

    def unique_name(self, filename):
        if os.path.exists(filename):
            n = 1
            fn, ext = os.path.splitext(filename)
            if ext:
                dot = os.path.extsep
            else:
                dot = ""
            while os.path.exists("%s%s%s%s" % (fn, n, dot, ext)):
                n += 1
            return "%s%s%s%s" % (fn, n, dot, ext)
        else:
            return filename

    def check_for_sleep(self):
        if self.terminated:
            raise Exception("Exporter Terminated!")
        while self.suspended:
            if self.terminated:
                debug("Thread Terminated!", 0)
                raise Exception("Exporter Terminated!")
            if use_threads:
                time.sleep(1)
            else:
                time.sleep(0.1)

    def terminate(self):
        self.terminated = True

    def suspend(self):
        self.suspended = True

    def resume(self):
        self.suspended = False
