from __future__ import absolute_import

import os
import collections

try:
    from cStringIO import StringIO as BytesIO
except ImportError:  # pragma: no cover
    from io import BytesIO
from lxml import etree
from babel.messages import pofile, Catalog
from termcolor import colored

import convert
from env import resolve_locale

__all__ = ('CommandError', 'ExportCommand', 'ImportCommand', 'InitCommand',)


class CommandError(Exception):
    pass


def read_catalog(filename, **kwargs):
    """Helper to read a catalog from a .po file.
    """
    f = open(filename, 'r')
    try:
        return pofile.read_po(f, **kwargs)
    finally:
        f.close()


def catalog2string(catalog, **kwargs):
    """Helper that returns a babel message catalog as a string.

    This is a simple shortcut around pofile.write_po().
    """
    sf = BytesIO()
    pofile.write_po(sf, catalog, **kwargs)
    return sf.getvalue().decode('utf-8')


def xml2string(tree, action):
    """Helper that returns a ``ResourceTree`` as an XML string.

    TODO: It would be cool if this could try to recreate the formatting
    of the original xml file.
    """
    ENCODING = 'utf-8'
    dom = convert.write_xml(tree, warnfunc=action.message)
    return etree.tostring(dom, xml_declaration=True,
                          encoding=ENCODING, pretty_print=True).decode('utf-8')


def read_xml(action, filename, **kw):
    """Wrapper around the base read_xml() that pipes warnings
    into the given action.

    Also handles errors and returns false if the file is invalid.
    """
    try:
        return convert.read_xml(filename, warnfunc=action.message, **kw)
    except convert.InvalidResourceError as e:
        action.done('failed')
        action.message('Failed parsing "%s": %s' % (filename.rel, e), 'error')
        return False


def xml2po(env, action, *a, **kw):
    """Wrapper around the base xml2po() that uses the filters configured
    by the environment.
    """

    def xml_filter(name):
        for filter in env.config.ignores:
            if filter.match(name):
                return True

    kw['resfilter'] = xml_filter
    if action:
        kw['warnfunc'] = action.message
    return convert.xml2po(*a, **kw)


def po2xml(env, action, *a, **kw):
    """Wrapper around the base po2xml() that uses the filters configured
    by the environment.
    """

    def po_filter(message):
        if env.config.ignore_fuzzy and message.fuzzy:
            return True

    kw['resfilter'] = po_filter
    kw['warnfunc'] = action.message
    return convert.po2xml(*a, **kw)


def get_catalog_counts(catalog):
    """Return 3-tuple (total count, number of translated strings, number
    of fuzzy strings), based on the given gettext catalog.
    """
    # Make sure we don't count the header
    return (len(catalog),
            len([m for m in catalog if m.string and m.id]),
            len([m for m in catalog if m.string and m.id and m.fuzzy]))


def list_languages(source, env, writer):
    """Return a list of languages (by simply calling the proper
    environment method.

    However, commands should use this helper rather than working
    with the environment directly, as this outputs helpful
    diagnostic messages along the way.
    """
    assert source in ('gettext', 'android')
    languages = getattr(
        env,
        'get_gettext_languages' if source == 'gettext' else 'get_android_languages')()
    lstr = ", ".join(map(str, languages))
    writer.action('info',
                  "Found %d language(s): %s" % (len(languages), lstr))
    writer.message('List of languages was based on %s' % (
        'the existing gettext catalogs' if source == 'gettext'
        else 'the existing Android resource directories'
    ))
    return languages


def ensure_directories(cmd, path):
    """Ensure that the given directory exists.
    """
    # Collect all the individual directories we need to create.
    # Yes, I know about os.makedirs(), but I'd like to print out
    # every single directory created.
    needs_creating = []
    while not path.exists():
        if path in needs_creating:
            break
        needs_creating.append(path)
        path = path.dir

    for path in reversed(needs_creating):
        cmd.w.action('mkdir', path)
        os.mkdir(path)


def write_file(cmd, filename, content, update=True, action=None,
               ignore_exists=False):
    """Helper that writes a file, while sending the proper actions
    to the command's writer for stdout display of what's going on.

    ``content`` may be a callable. This is useful if you would like
    to exploit the ``update=False`` check this function provides,
    rather than doing that yourself before bothering to generate the
    content you want to write.

    When ``update`` is not set, then if the file already exists we don't
    change or overwrite it.

    If a Writer.Action is given in ``action``, it will be used to print
    out messages. Otherwise, a new action will be started using the
    filename as the text. If ``action`` is ``False``, nothing will be
    printed.
    """
    if action is None:
        action = cmd.w.begin(filename)

    if filename.exists():
        if not update:
            if ignore_exists:
                # Downgade level of this message
                action.update(severity='info')
            action.done('exists')
            return False
        else:
            old_hash = filename.hash()
    else:
        old_hash = None

    ensure_directories(cmd, filename.dir)

    f = open(filename, 'wb')
    try:
        if isinstance(content, collections.Callable):
            content = content()
        f.write(content.encode('utf-8'))
        f.flush()
    finally:
        f.close()

    if action is not False:
        if old_hash is None:
            action.done('created')
        elif old_hash != filename.hash():
            action.done('updated')
        else:
            # Note that this is merely for user information. We
            # nevertheless wrote a new version of the file, we can't
            # actually determine a change without generating the new
            # version.
            action.done('unchanged')
    return True


class Command(object):
    """Abstract base command class.
    """

    def __init__(self, env, writer):
        self.env = env
        self.w = writer

    @classmethod
    def setup_arg_parser(cls, argparser):
        """A command should register it's sub-arguments here with the
        given argparser instance.
        """

    def execute(self):
        raise NotImplementedError()


class InitCommand(Command):
    """The init command; to initialize new languages.
    """

    @classmethod
    def setup_arg_parser(cls, parser):
        parser.add_argument('language', nargs='*',
                            help='Language code to initialize. If none given, all ' +
                                 'languages lacking a .po file will be initialized.')

    def make_or_get_template(self, kind, read_action=None, do_write=False,
                             update=True):
        """Return the .pot template file (as a Catalog) for the given kind.

        If ``do_write`` is given, the template file will be saved in the
        proper location. If ``update`` is ``False``, then an existing file
        will not be overridden, however.

        If ``do_write`` is disabled, then you need to given ``read_action``,
        the action which needs the template. This is so we can fail the
        proper action if generating the template goes wrong.

        Once generated, the template will be cached as a class member,
        and on subsequent access the cached version is returned.
        """
        # Implement caching - only generate the catalog the first time
        # this function is called.
        if not hasattr(self, '_template_catalogs'):
            self._template_catalogs = {}

        if kind in self._template_catalogs:
            return self._template_catalogs[kind], False

        # Only one, xor the other.
        assert read_action or do_write and not (read_action and do_write)

        template_pot = self.env.default.po(kind)
        if do_write:
            action = self.w.begin(template_pot)
        else:
            action = read_action

        # Read the XML, bail out if that fails
        xmldata = read_xml(action, self.env.default.xml(kind))
        if xmldata is False:
            return False, False

        # Actually generate the catalog
        template_catalog = xml2po(self.env, action, xmldata)
        self._template_catalogs[kind] = template_catalog

        # Write the catalog as a template to disk if necessary.
        something_written = False
        if do_write:
            # Note that this is always rendered with "ignore_exists",
            # i.e. we only log this action if we change the template.
            if write_file(self, template_pot,
                          content=lambda: catalog2string(template_catalog),
                          action=action, ignore_exists=True, update=update):
                something_written = True

        return template_catalog, something_written

    def generate_templates(self, update=True):
        """Generate the template files.

        Do this only if they are not disabled.
        """
        something_written = False
        if not self.env.config.no_template:
            for kind in self.env.xmlfiles:
                _, write_happend = self.make_or_get_template(
                    kind, do_write=True, update=update)
                if write_happend:
                    something_written = True
        return something_written

    def generate_po(self, target_po_file, default_data, action,
                    language_data=None, language_data_files=None,
                    update=True, ignore_exists=False):
        """Helper to generate a .po file.

        ``default_data`` is the collective data from the language neutral XML
        files, and this is what the .po we generate will be based on.

        ``language_data`` is collective data from the corresponding
        language-specific XML files, in case such data is available.

        ``language_data_files`` is the list of files that ``language_data``
        is based upon. This is because in some cases multiple XML files
        might need to be combined into one gettext catalog.

        If ``update`` is not set than we will bail out early
        if the file doesn't exist.
        """

        # This is a function so that it only will be run if write_file()
        # actually needs it.
        def make_catalog():
            if language_data is not None:
                action.message('Using existing translations from %s' % ", ".join(
                    [l.rel for l in language_data_files]))
                lang_catalog, unmatched = xml2po(self.env, action,
                                                 default_data,
                                                 language_data)
                if unmatched:
                    action.message("Existing translation XML files for this "
                                   "language contains strings not found in the "
                                   "default XML files: %s" % (", ".join(unmatched)))
            else:
                action.message('No corresponding XML exists, generating catalog ' +
                               'without translations')
                lang_catalog = xml2po(self.env, action, default_data)

            catalog = catalog2string(lang_catalog)

            num_total, num_translated, _ = get_catalog_counts(lang_catalog)
            action.message("%d strings processed, %d translated." % (
                num_total, num_translated))
            return catalog

        return write_file(self, target_po_file, content=make_catalog,
                          action=action, update=update,
                          ignore_exists=ignore_exists)

    def _iterate(self, language, require_translation=True):
        """Yield 5-tuples in the form of: (
            action object,
            target .po file,
            source xml data,
            translated xml data,
            list of files translated xml data was read from
        )

        This is implemeted as a separate iterator so that later on we can
        also support a mechanism in which multiple xml files are stored in
        one .po file, i.e. on export, multiple xml files needs to be able
        to yield into a single .po target.
        """
        for kind in self.env.xmlfiles:
            language_po = language.po(kind)
            language_xml = language.xml(kind)

            action = self.w.begin(language_po)

            language_data = None
            if not language_xml.exists():
                if require_translation:
                    # It's easily possible that say a arrays.xml only
                    # exists in values/, but not in values-xx/.
                    action.done('skipped')
                    action.message('%s doesn\'t exist' % language_po.rel,
                                   'warning')
                    continue
            else:
                language_data = read_xml(action, language_xml, language=language)
                if not language_data:
                    # File was invalid
                    continue

            template_data = read_xml(action, self.env.default.xml(kind))
            if template_data is False:
                # File was invalid
                continue

            yield action, language_po, template_data, language_data, [language_xml]

    def yield_languages(self, env, source='android'):
        if env.options.language:
            for code in env.options.language:
                if code == '-':
                    # This allows specifying - to only build the template
                    continue
                language = resolve_locale(code, env)
                if language:
                    yield language

        else:
            for l in list_languages(source, env, self.w):
                yield l

    def execute(self):
        env = self.env

        # First, make sure the templates exist. This makes the "init"
        # command everything needed to bootstrap.
        # TODO: Test that this happens.
        something_done = self.generate_templates(update=False)

        # Only show [exists] actions if a specific language was requested.
        show_exists = not bool(env.options.language)

        for language in self.yield_languages(env):
            # For each language, generate a .po file. In case a language
            # already exists (that is, it's xml files exist, use the
            # existing translations for the new gettext catalog).
            for (action,
                 target_po,
                 template_data,
                 lang_data,
                 lang_files) in self._iterate(language, require_translation=False):
                if self.generate_po(target_po, template_data, action,
                                    lang_data, lang_files,
                                    update=False,
                                    ignore_exists=show_exists):
                    something_done = True

            # Also for each language, generate the empty .xml resource files.
            # This will make us pick up the language on subsequent runs.
            for kind in self.env.xmlfiles:
                if write_file(self, language.xml(kind),
                              """<?xml version='1.0' encoding='utf-8'?>\n<resources>\n</resources>""",
                              update=False, ignore_exists=show_exists):
                    something_done = True

        if not something_done:
            self.w.action('info', 'Nothing to do.', 'default')


class ExportCommand(InitCommand):
    """The export command.

    Inherits from ``InitCommand`` to be able to use ``generate_templates``.
    Both commands need to write the templates.
    """

    @classmethod
    def setup_arg_parser(cls, parser):
        parser.add_argument(
            'language', nargs='*',
            help='Language code to export. If not given, all ' +
                 'initialized languages will be exported.')

    def execute(self):
        env = self.env
        w = self.w

        # First, always update the template files. Note that even if
        # template generation is disabled, we still need to have the
        # catalogs at least in memory for the updating process later on.
        #
        # TODO: Do we really want to regenerate the templates every
        # time, or should the user be able to set fixed meta data, and
        # we simply merge subsequent updates in?
        self.generate_templates()

        initial_warning = False

        for language in self.yield_languages(env, 'gettext'):
            for kind in self.env.xmlfiles:
                target_po = language.po(kind)
                if not target_po.exists():
                    w.action('skipped', target_po)
                    w.message('File does not exist yet. ' +
                              'Use the \'init\' command.')
                    initial_warning = True
                    continue

                action = w.begin(target_po)
                # If we do not provide a locale, babel will consider this
                # catalog a template and always write out the default
                # header. It seemingly does not consider the "Language"
                # header inside the file at all, and indeed deletes it.
                # TODO: It deletes all headers it doesn't know, and
                # overrides others. That sucks.

                # Pontoon creates folders like zh-tw, but babel expects zh_tw
                locale = language.code.replace('-', '_')

                lang_catalog = read_catalog(target_po, locale=locale)
                catalog, _ = self.make_or_get_template(kind, action)
                if catalog is None:
                    # Something went wrong parsing the catalog
                    continue
                lang_catalog.update(catalog,
                                    no_fuzzy_matching=not env.config.enable_fuzzy_matching)

                # Making monkey patching: getting values from obsolete values and
                # setting them as the new ones while marking message fuzzy
                for message in lang_catalog:
                    for key in lang_catalog.obsolete:
                        if key == "":
                            continue
                        if message.context == key[1]:
                            obsolete_message = lang_catalog.obsolete[key]
                            message.string = obsolete_message.string
                            message.flags.add('fuzzy')
                # Clearing obsolete messages
                if env.config.clear_obsolete:
                    lang_catalog.obsolete.clear()

                # Set the correct plural forms.
                current_plurals = lang_catalog.plural_forms
                convert.set_catalog_plural_forms(lang_catalog, language)
                if lang_catalog.plural_forms != current_plurals:
                    action.message(
                        'The Plural-Forms header of this catalog '
                        'has been updated to what android2po '
                        'requires for plurals support. See the '
                        'README for more information.', 'warning')

                # TODO: Should we include previous?
                write_file(self, target_po,
                           catalog2string(lang_catalog, include_previous=False),
                           action=action)

        if initial_warning:
            print("")
            print(colored("Warning: One or more .po files were skipped " +
                          "because they did not exist yet. Use the 'init' command " +
                          "to generate them for the first time.",
                          color='magenta', attrs=['bold']))


class ImportCommand(Command):
    """The import command.
    """

    def process(self, language):
        """Process importing the given language.
        """

        # In order to implement the --require-min-complete option, we need
        # to first determine the translation status across all .po catalogs
        # for this language. We can keep the catalogs in memory because we
        # will need them later anyway.
        catalogs = {}
        count_total = 0
        count_translated = 0
        for kind in self.env.xmlfiles:
            language_po = language.po(kind)
            if not language_po.exists():
                continue
            catalogs[kind] = catalog = read_catalog(language_po)
            catalog.language = language
            ntotal, ntrans, nfuzzy = get_catalog_counts(catalog)
            count_total += ntotal
            count_translated += ntrans
            if self.env.config.ignore_fuzzy:
                count_translated -= nfuzzy

        # Compare our count with what is required, if anything.
        skip_due_to_incomplete = False
        min_required = self.env.config.min_completion
        if count_total == 0:
            actual_completeness = 1
        else:
            actual_completeness = count_translated / float(count_total)
        if min_required:
            skip_due_to_incomplete = actual_completeness < min_required

        # Now loop through the list of target files, and either create
        # them, or print a status message for each indicating that they
        # were skipped.
        for kind in self.env.xmlfiles:
            language_xml = language.xml(kind)
            language_po = language.po(kind)
            action = self.w.begin(language_xml)

            if skip_due_to_incomplete:
                # TODO: Creating a catalog object here is kind of clunky.
                # Idially, we'd refactor convert.py so that we can use a
                # dict to represent a resource XML file.
                xmldata = po2xml(self.env, action, Catalog(locale=language.code))
                write_file(self, language_xml, xml2string(xmldata, action),
                           action=False)
                action.done('skipped', status=('%s catalogs aren\'t '
                                               'complete enough - %.2f done' % (
                                                   language.code,
                                                   actual_completeness)))
                continue

            if not language_po.exists():
                action.done('skipped')
                self.w.message('%s doesn\'t exist' % language_po.rel, 'warning')
                continue

            content = xml2string(po2xml(self.env, action, catalogs[kind]), action)
            write_file(self, language_xml, content, action=action)

    def execute(self):
        for language in list_languages('gettext', self.env, self.w):
            self.process(language)
