#
# Copyright 2010 Zuza Software Foundation
#
# This file is part of the Translate Toolkit.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see <http://www.gnu.org/licenses/>.

import contextlib
import os

from lxml import etree

__all__ = ("FileExistsInProjectError", "FileNotInProjectError", "ProjectStore")


class FileExistsInProjectError(Exception):
    pass


class FileNotInProjectError(Exception):
    pass


class ProjectStore:
    """Basic project file container."""

    # INITIALIZERS #
    def __init__(self) -> None:
        self._files = {}
        self._sourcefiles = []
        self._targetfiles = []
        self._transfiles = []
        self.settings = {}
        self.convert_map = {}
        # The above map maps the conversion of input files (keys) to its output
        # file and template used (2-tuple). All values are project file names.
        # eg. convert_map = {
        #    'sources/doc.odt':   ('trans/doc.odt.xlf', None),
        #    'trans/doc.odt.xlf': ('targets/doc.odt', 'sources/doc.odt')
        # }

        # The following dict groups together sets of mappings from a file
        # "type" string ("src", "tgt" or "trans") to various other values
        # or objects.
        self.TYPE_INFO = {
            # type => prefix for new files
            "f_prefix": {
                "src": "sources/",
                "tgt": "targets/",
                "trans": "trans/",
            },
            # type => list containing filenames for that type
            "lists": {
                "src": self._sourcefiles,
                "tgt": self._targetfiles,
                "trans": self._transfiles,
            },
            # type => next type in process: src => trans => tgt
            "next_type": {
                "src": "trans",
                "trans": "tgt",
                "tgt": None,
            },
            # type => name of the sub-section in the settings file/dict
            "settings": {
                "src": "sources",
                "tgt": "targets",
                "trans": "transfiles",
            },
        }

    def __del__(self) -> None:
        with contextlib.suppress(Exception):
            self.close()

    # ACCESSORS #
    def _get_sourcefiles(self):
        """Read-only access to ``self._sourcefiles``."""
        return tuple(self._sourcefiles)

    sourcefiles = property(_get_sourcefiles)

    def _get_targetfiles(self):
        """Read-only access to ``self._targetfiles``."""
        return tuple(self._targetfiles)

    targetfiles = property(_get_targetfiles)

    def _get_transfiles(self):
        """Read-only access to ``self._transfiles``."""
        return tuple(self._transfiles)

    transfiles = property(_get_transfiles)

    # SPECIAL METHODS #
    def __contains__(self, lhs) -> bool:
        """@returns ``True`` if ``lhs`` is a file name or file object in the project store."""
        return (
            lhs in self._sourcefiles
            or lhs in self._targetfiles
            or lhs in self._transfiles
            or lhs in self._files
            or lhs in self._files.values()
        )

    # METHODS #
    def append_file(
        self, afile, fname, ftype: str = "trans", delete_orig: bool = False
    ):
        """
        Append the given file to the project with the given filename, marked
        to be of type ``ftype`` ('src', 'trans', 'tgt').

        :param delete_orig: Whether or not the original (given) file should be
                            deleted after being appended. This is set to
                            ``True`` by
                            :meth:`~translate.storage.project.convert_forward`
                            . Not used in this class.
        """
        if ftype not in self.TYPE_INFO["f_prefix"]:
            raise ValueError(f"Invalid file type: {ftype}")

        if isinstance(afile, str) and os.path.isfile(afile) and not fname:
            # Try and use afile as the file name
            fname, afile = afile, open(afile)

        # Check if we can get an real file name
        realfname = fname
        if realfname is None or not os.path.isfile(realfname):
            realfname = getattr(afile, "name", None)
        if realfname is None or not os.path.isfile(realfname):
            realfname = getattr(afile, "filename", None)
        if not realfname or not os.path.isfile(realfname):
            realfname = None

        # Try to get the file name from the file object, if it was not given:
        if not fname:
            fname = getattr(afile, "name", None)
        if not fname:
            fname = getattr(afile, "filename", None)

        fname = self._fix_type_filename(ftype, fname)

        if not fname:
            raise ValueError("Could not deduce file name and none given")
        if fname in self._files:
            raise FileExistsInProjectError(fname)

        if realfname is not None and os.path.isfile(realfname):
            self._files[fname] = realfname
        else:
            self._files[fname] = afile
        self.TYPE_INFO["lists"][ftype].append(fname)

        return afile, fname

    def append_sourcefile(self, afile, fname=None):
        return self.append_file(afile, fname, ftype="src")

    def append_targetfile(self, afile, fname=None):
        return self.append_file(afile, fname, ftype="tgt")

    def append_transfile(self, afile, fname=None):
        return self.append_file(afile, fname, ftype="trans")

    def remove_file(self, fname, ftype=None) -> None:
        """
        Remove the file with the given project name from the project.  If
        the file type ('src', 'trans' or 'tgt') is not given, it is guessed.
        """
        if fname not in self._files:
            raise FileNotInProjectError(fname)
        if not ftype:
            # Guess file type (source/trans/target)
            for ft, prefix in self.TYPE_INFO["f_prefix"].items():
                if fname.startswith(prefix):
                    ftype = ft
                    break

        self.TYPE_INFO["lists"][ftype].remove(fname)
        if self._files[fname] and hasattr(self._files[fname], "close"):
            self._files[fname].close()
        del self._files[fname]

    def remove_sourcefile(self, fname) -> None:
        self.remove_file(fname, ftype="src")

    def remove_targetfile(self, fname) -> None:
        self.remove_file(fname, ftype="tgt")

    def remove_transfile(self, fname) -> None:
        self.remove_file(fname, ftype="trans")

    def close(self) -> None:
        self.save()
        self.cleanup()

    def cleanup(self) -> None:
        for handle in self._files.values():
            if handle is not None and not isinstance(handle, str):
                handle.close()

    def get_file(self, fname: str, mode: str = "rb"):
        """
        Retrieve the file with the given name from the project store.

        The file is looked up in the ``self._files`` dictionary. The values
        in this dictionary may be ``None``, to indicate that the file is not
        cacheable and needs to be retrieved in a special way. This special
        way must be defined in this method of sub-classes. The value may
        also be a string, which indicates that it is a real file accessible
        via ``open``.
        :param mode: The mode in which to re-open the file (if it is closed).
        """
        if fname not in self._files:
            raise FileNotInProjectError(fname)

        rfile = self._files[fname]
        if isinstance(rfile, str):
            rfile = open(rfile, "rb")
        # Check that the file is actually open
        if getattr(rfile, "closed", False):
            rfname = fname
            if not os.path.isfile(rfname):
                rfname = getattr(rfile, "name", None)
            if not rfile or not os.path.isfile(rfname):
                rfname = getattr(rfile, "filename", None)
            if not rfile or not os.path.isfile(rfname):
                raise OSError(f"Could not locate file: {rfile} ({fname})")
            rfile = open(rfname, mode)
            self._files[fname] = rfile

        return rfile

    def get_filename_type(self, fname):
        """Get the type of file ('src', 'trans', 'tgt') with the given name."""
        for ftype in self.TYPE_INFO["lists"]:
            if fname in self.TYPE_INFO["lists"][ftype]:
                return ftype
        raise FileNotInProjectError(fname)

    def get_proj_filename(self, realfname):
        """Try and find a project file name for the given real file name."""
        for fname, value in self._files.items():
            if realfname in {fname, value}:
                return fname
        raise ValueError(f"Real file not in project store: {realfname}")

    def load(self, *args, **kwargs) -> None:
        """Load the project in some way. Undefined for this (base) class."""

    def save(self, filename=None, *args, **kwargs) -> None:
        """Save the project in some way. Undefined for this (base) class."""

    def update_file(self, pfname, infile) -> None:
        """
        Remove the project file with name ``pfname`` and add the contents
        from ``infile`` to the project under the same file name.

        :returns: the results from :meth:`ProjectStore.append_file`.
        """
        ftype = self.get_filename_type(pfname)
        self.remove_file(pfname)
        self.append_file(infile, pfname, ftype)

    def _fix_type_filename(self, ftype, fname):
        """Strip the path from the filename and prepend the correct prefix."""
        fname = os.path.basename(fname)
        return self.TYPE_INFO["f_prefix"][ftype] + fname

    def _generate_settings(self):
        """@returns A XML string that represents the current settings."""
        xml = etree.Element("translationproject")

        # Add file names to settings XML
        if self._sourcefiles:
            sources_el = etree.Element("sources")
            for fname in self._sourcefiles:
                src_el = etree.Element("filename")
                src_el.text = fname
                sources_el.append(src_el)
            xml.append(sources_el)
        if self._transfiles:
            transfiles_el = etree.Element("transfiles")
            for fname in self._transfiles:
                trans_el = etree.Element("filename")
                trans_el.text = fname
                transfiles_el.append(trans_el)
            xml.append(transfiles_el)
        if self._targetfiles:
            target_el = etree.Element("targets")
            for fname in self._targetfiles:
                tgt_el = etree.Element("filename")
                tgt_el.text = fname
                target_el.append(tgt_el)
            xml.append(target_el)

        # Add conversion mappings
        if self.convert_map:
            conversions_el = etree.Element("conversions")
            for in_fname, (out_fname, templ_fname) in self.convert_map.items():
                if in_fname not in self._files or out_fname not in self._files:
                    continue
                conv_el = etree.Element("conv")

                input_el = etree.Element("input")
                input_el.text = in_fname
                conv_el.append(input_el)

                output_el = etree.Element("output")
                output_el.text = out_fname
                conv_el.append(output_el)

                if templ_fname:
                    templ_el = etree.Element("template")
                    templ_el.text = templ_fname
                    conv_el.append(templ_el)

                conversions_el.append(conv_el)
            xml.append(conversions_el)

        # Add options to settings
        if "options" in self.settings:
            options_el = etree.Element("options")
            for option, value in self.settings["options"].items():
                opt_el = etree.Element("option")
                opt_el.attrib["name"] = option
                opt_el.text = value
                options_el.append(opt_el)
            xml.append(options_el)

        return etree.tostring(xml, pretty_print=True)

    def _load_settings(self, settingsxml) -> None:
        """
        Load project settings from the given XML string.  ``settingsxml`` is
        parsed into a DOM tree (``lxml.etree.fromstring``) which is then
        inspected.
        """
        settings = {}
        xml = etree.fromstring(settingsxml)

        # Load files in project
        for section in ("sources", "targets", "transfiles"):
            groupnode = xml.find(section)
            if groupnode is None:
                continue

            settings[section] = []
            for fnode in groupnode.getchildren():
                settings[section].append(fnode.text)

        conversions_el = xml.find("conversions")
        if conversions_el is not None:
            self.convert_map = {}
            for conv_el in conversions_el.iterchildren():
                in_fname, out_fname, templ_fname = None, None, None
                for child_el in conv_el.iterchildren():
                    if child_el.tag == "input":
                        in_fname = child_el.text
                    elif child_el.tag == "output":
                        out_fname = child_el.text
                    elif child_el.tag == "template":
                        templ_fname = child_el.text
                # Make sure that in_fname and out_fname exist in
                # settings['sources'], settings['targets'] or
                # settings['transfiles']
                in_found, out_found, templ_found = False, False, False
                for section in ("sources", "transfiles", "targets"):
                    if section not in settings:
                        continue
                    if in_fname in settings[section]:
                        in_found = True
                    if out_fname in settings[section]:
                        out_found = True
                    if templ_fname and templ_fname in settings[section]:
                        templ_found = True
                if in_found and out_found and (not templ_fname or templ_found):
                    self.convert_map[in_fname] = (out_fname, templ_fname)

        # Load options
        groupnode = xml.find("options")
        if groupnode is not None:
            settings["options"] = {}
            for opt in groupnode.iterchildren():
                settings["options"][opt.attrib["name"]] = opt.text

        self.settings = settings
