#
# Copyright 2004-2006 Zuza Software Foundation
#
# This file is part of translate.
#
# translate 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.
#
# translate 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/>.

"""
Handles converting of files between formats (used by
:mod:`translate.convert` tools).
"""

import os.path
from io import BytesIO

from translate.misc import optrecurse
from translate.tools import pocount

# Don't import optparse ourselves, get the version from optrecurse.
optparse = optrecurse.optparse


class ConvertOptionParser(optrecurse.RecursiveOptionParser):
    """A specialized Option Parser for convertor tools..."""

    def __init__(
        self,
        formats,
        usetemplates=False,
        usepots=False,
        allowmissingtemplate=False,
        description=None,
    ) -> None:
        """Construct the specialized Option Parser."""
        super().__init__(
            formats,
            usetemplates,
            allowmissingtemplate=allowmissingtemplate,
            description=description,
        )
        self.usepots = usepots
        self.settimestampoption()
        self.setpotoption()
        self.set_usage()

    def add_fuzzy_option(self, default=False) -> None:
        """Adds an option to include / exclude fuzzy translations."""
        fuzzyhelp = "use translations marked fuzzy"
        nofuzzyhelp = "don't use translations marked fuzzy"
        if default:
            fuzzyhelp += " (default)"
        else:
            nofuzzyhelp += " (default)"
        self.add_option(
            "",
            "--fuzzy",
            dest="includefuzzy",
            action="store_true",
            default=default,
            help=fuzzyhelp,
        )
        self.add_option(
            "",
            "--nofuzzy",
            dest="includefuzzy",
            action="store_false",
            default=default,
            help=nofuzzyhelp,
        )
        self.passthrough.append("includefuzzy")

    def add_remove_untranslated_option(self, default=False) -> None:
        """
        Adds an option to remove key value from output if it is
        untranslated.
        """
        self.add_option(
            "",
            "--removeuntranslated",
            dest="remove_untranslated",
            default=False,
            action="store_true",
            help="remove untranslated strings from output",
        )
        self.passthrough.append("remove_untranslated")

    def add_threshold_option(self, default=None) -> None:
        """
        Adds an option to output only stores where translation percentage
        exceeds the threshold.
        """
        self.add_option(
            "",
            "--threshold",
            dest="outputthreshold",
            default=default,
            metavar="PERCENT",
            type="int",
            help="only convert files where the translation completion is above PERCENT",
        )
        self.passthrough.append("outputthreshold")

    def add_duplicates_option(self, default="msgctxt") -> None:
        """Adds an option to say what to do with duplicate strings."""
        self.add_option(
            "",
            "--duplicates",
            dest="duplicatestyle",
            default=default,
            type="choice",
            choices=["msgctxt", "merge"],
            help=f"what to do with duplicate strings (identical source text): merge, msgctxt (default: '{default}')",
            metavar="DUPLICATESTYLE",
        )
        self.passthrough.append("duplicatestyle")

    def add_multifile_option(self, default="single") -> None:
        """Adds an option to say how to split the po/pot files."""
        self.add_option(
            "",
            "--multifile",
            dest="multifilestyle",
            default=default,
            type="choice",
            choices=["single", "toplevel", "onefile"],
            help="how to split po/pot files (single, toplevel or onefile)",
            metavar="MULTIFILESTYLE",
        )
        self.passthrough.append("multifilestyle")

    @staticmethod
    def potifyformat(fileformat):
        """Converts a .po to a .pot where required."""
        if fileformat is None:
            return fileformat
        if fileformat == "po":
            return "pot"
        if fileformat.endswith(f"{os.extsep}po"):
            return f"{fileformat}t"
        return fileformat

    def getformathelp(self, formats):
        """Make a nice help string for describing formats..."""
        # include implicit pot options...
        helpformats = []
        for fileformat in formats:
            helpformats.append(fileformat)
            potformat = self.potifyformat(fileformat)
            if potformat != fileformat:
                helpformats.append(potformat)
        return super().getformathelp(helpformats)

    def filterinputformats(self, options):
        """Filters input formats, processing relevant switches in options."""
        if self.usepots and options.pot:
            return [self.potifyformat(inputformat) for inputformat in self.inputformats]
        return self.inputformats

    def filteroutputoptions(self, options):
        """Filters output options, processing relevant switches in options."""
        if self.usepots and options.pot:
            outputoptions = {}
            for (inputformat, templateformat), (
                outputformat,
                convertor,
            ) in self.outputoptions.items():
                inputformat = self.potifyformat(inputformat)
                templateformat = self.potifyformat(templateformat)
                outputformat = self.potifyformat(outputformat)
                outputoptions[inputformat, templateformat] = (outputformat, convertor)
            return outputoptions
        return self.outputoptions

    def setpotoption(self) -> None:
        """
        Sets the ``-P``/``--pot`` option depending on input/output formats
        etc.
        """
        if self.usepots:
            potoption = optparse.Option(
                "-P",
                "--pot",
                action="store_true",
                dest="pot",
                default=False,
                help="output PO Templates (.pot) rather than PO files (.po)",
            )
            self.define_option(potoption)

    def settimestampoption(self) -> None:
        """Sets ``-S``/``--timestamp`` option."""
        timestampopt = optparse.Option(
            "-S",
            "--timestamp",
            action="store_true",
            dest="timestamp",
            default=False,
            help="skip conversion if the output file has newer timestamp",
        )
        self.define_option(timestampopt)

    def verifyoptions(self, options) -> None:
        """
        Verifies that the options are valid (required options are present,
        etc).
        """

    def run(self, argv=None) -> None:
        """Parses the command line options and runs the conversion."""
        options, _args = self.parse_args(argv)
        self.inputformats = self.filterinputformats(options)
        self.outputoptions = self.filteroutputoptions(options)
        try:
            self.verifyoptions(options)
        except Exception as e:
            self.error(str(e))
        self.recursiveprocess(options)

    def processfile(
        self, fileprocessor, options, fullinputpath, fulloutputpath, fulltemplatepath
    ):
        # The `fulloutputpath`s parsed when `onefile` is enabled don't exist, which causes
        # the `_output_is_newer` check to return False. This means that even if there is
        # no content update, the POT/PO files are regenerated. When `onefile` is enabled,
        # this check sets the output path used by `_output_is_newer` to the output file
        # provided to the command, which will exist. It remains `fulloutputpath` otherwise.
        if getattr(options, "multifilestyle", None) == "onefile":
            timecheck_output_path = options.output
        else:
            timecheck_output_path = fulloutputpath

        if options.timestamp and _output_is_newer(fullinputpath, timecheck_output_path):
            return False

        return super().processfile(
            fileprocessor, options, fullinputpath, fulloutputpath, fulltemplatepath
        )


def copyinput(inputfile, outputfile, templatefile, **kwargs) -> bool:
    """Copies the input file to the output file."""
    outputfile.write(inputfile.read())
    return True


def copytemplate(inputfile, outputfile, templatefile, **kwargs) -> bool:
    """Copies the template file to the output file."""
    outputfile.write(templatefile.read())
    return True


class Replacer:
    """An object that knows how to replace strings in files."""

    def __init__(self, searchstring, replacestring) -> None:
        self.searchstring = searchstring
        self.replacestring = replacestring

    def doreplace(self, text):
        """Actually replace the text."""
        if self.searchstring is not None and self.replacestring is not None:
            return text.replace(self.searchstring, self.replacestring)
        return text

    def searchreplaceinput(self, inputfile, outputfile, templatefile, **kwargs) -> bool:
        """Copies the input file to the output file, searching and replacing."""
        outputfile.write(self.doreplace(inputfile.read()))
        return True

    def searchreplacetemplate(
        self, inputfile, outputfile, templatefile, **kwargs
    ) -> bool:
        """
        Copies the template file to the output file, searching and
        replacing.
        """
        outputfile.write(self.doreplace(templatefile.read()))
        return True


# archive files need to know how to:
# - openarchive: creates an archive object for the archivefilename
#   * requires a constructor that takes the filename
# - iterarchivefile: iterate through the names in the archivefile
#   * requires the default iterator to do this
# - archivefileexists: check if a given pathname exists inside the archivefile
#   * uses the in operator - requires __contains__ (or will use __iter__
#     by default)
# - openarchiveinputfile: returns an open input file from the archive, given
#   the path
#   * requires an archivefile.openinputfile method that takes the pathname
# - openarchiveoutputfile: returns an open output file from the archive, given
#   the path
#   * requires an archivefile.openoutputfile method that takes the pathname


class ArchiveConvertOptionParser(ConvertOptionParser):
    """
    ConvertOptionParser that can handle recursing into single archive files.

    ``archiveformats`` maps extension to class. If the extension doesn't
    matter, it can be None.

    If the extension is only valid for input/output/template, it can be given
    as ``(extension, filepurpose)``.
    """

    def __init__(
        self,
        formats,
        usetemplates=False,
        usepots=False,
        description=None,
        archiveformats=None,
    ) -> None:
        if archiveformats is None:
            self.archiveformats = {}
        else:
            self.archiveformats = archiveformats
        self.archiveoptions = {}
        super().__init__(formats, usetemplates, usepots, description=description)

    def isrecursive(self, fileoption, filepurpose="input"):
        """Checks if **fileoption** is a recursive file."""
        if self.isarchive(fileoption, filepurpose):
            return True
        return super().isrecursive(fileoption, filepurpose)

    def isarchive(self, fileoption, filepurpose="input"):
        """Returns whether the file option is an archive file."""
        if not isinstance(fileoption, str):
            return False
        mustexist = filepurpose != "output"
        if mustexist and not os.path.isfile(fileoption):
            return False
        fileext = self.splitext(fileoption)[1]
        # if None is in the archive formats, then treat all non-directory
        # inputs as archives
        return (
            self.getarchiveclass(fileext, filepurpose, os.path.isdir(fileoption))
            is not None
        )

    def getarchiveclass(self, fileext, filepurpose, isdir=False):
        """Returns the archiveclass for the given fileext and filepurpose."""
        archiveclass = self.archiveformats.get(fileext, None)
        if archiveclass is not None:
            return archiveclass
        archiveclass = self.archiveformats.get((fileext, filepurpose), None)
        if archiveclass is not None:
            return archiveclass
        if not isdir:
            archiveclass = self.archiveformats.get(None, None)
            if archiveclass is not None:
                return archiveclass
            archiveclass = self.archiveformats.get((None, filepurpose), None)
            if archiveclass is not None:
                return archiveclass
        return None

    def openarchive(self, archivefilename, filepurpose, **kwargs):
        """Creates an archive object for the given file."""
        archiveext = self.splitext(archivefilename)[1]
        archiveclass = self.getarchiveclass(
            archiveext, filepurpose, os.path.isdir(archivefilename)
        )
        archiveoptions = self.archiveoptions.copy()
        archiveoptions.update(kwargs)
        return archiveclass(archivefilename, **archiveoptions)

    def recurseinputfiles(self, options):
        """
        Recurse through archive file / directories and return files to be
        converted.
        """
        if self.isarchive(options.input, "input"):
            options.inputarchive = self.openarchive(options.input, "input")
            return self.recursearchivefiles(options)
        return super().recurseinputfiles(options)

    def recursearchivefiles(self, options):
        """Recurse through archive files and convert files."""
        inputfiles = []
        for inputpath in options.inputarchive:
            if self.isexcluded(options, inputpath):
                continue
            _top, name = os.path.split(inputpath)
            if not self.isvalidinputname(name):
                continue
            inputfiles.append(inputpath)
        return inputfiles

    def openinputfile(self, options, fullinputpath):
        """Opens the input file."""
        if self.isarchive(options.input, "input"):
            return options.inputarchive.openinputfile(fullinputpath)
        return super().openinputfile(options, fullinputpath)

    def getfullinputpath(self, options, inputpath):
        """Gets the absolute path to an input file."""
        if self.isarchive(options.input, "input"):
            return inputpath
        return os.path.join(options.input, inputpath)

    def opentemplatefile(self, options, fulltemplatepath):
        """Opens the template file (if required)."""
        if (
            fulltemplatepath is not None
            and options.recursivetemplate
            and self.isarchive(options.template, "template")
        ):
            # TODO: deal with different names in input/template archives
            if fulltemplatepath in self.templatearchive:
                return self.templatearchive.openinputfile(fulltemplatepath)
            self.warning(f"missing template file {fulltemplatepath}")
        return super().opentemplatefile(options, fulltemplatepath)

    def getfulltemplatepath(self, options, templatepath):
        """Gets the absolute path to a template file."""
        if templatepath is not None and self.usetemplates and options.template:
            if self.isarchive(options.template, "template"):
                return templatepath
            if not options.recursivetemplate:
                return templatepath
            return os.path.join(options.template, templatepath)
        return None

    def templateexists(self, options, templatepath):
        """Returns whether the given template exists..."""
        if templatepath is not None and self.isarchive(options.template, "template"):
            # TODO: deal with different names in input/template archives
            return templatepath in self.templatearchive
        return super().templateexists(options, templatepath)

    def getfulloutputpath(self, options, outputpath):
        """Gets the absolute path to an output file."""
        if self.isarchive(options.output, "output"):
            return outputpath
        if options.recursiveoutput and options.output:
            return os.path.join(options.output, outputpath)
        return outputpath

    def checkoutputsubdir(self, options, subdir) -> None:
        """
        Checks to see if subdir under ``options.output`` needs to be
        created, creates if necessary.
        """
        if not self.isarchive(options.output, "output"):
            super().checkoutputsubdir(options, subdir)

    def openoutputfile(self, options, fulloutputpath):
        """Opens the output file."""
        if self.isarchive(options.output, "output"):
            outputstream = self.outputarchive.openoutputfile(fulloutputpath)
            if outputstream is None:
                self.warning(
                    f"Could not find where to put {fulloutputpath} in output "
                    "archive; writing to tmp"
                )
                return BytesIO()
            return outputstream
        return super().openoutputfile(options, fulloutputpath)

    def recursiveprocess(self, options):
        """Recurse through directories and convert files."""
        if hasattr(options, "multifilestyle"):
            self.archiveoptions = {"multifilestyle": options.multifilestyle}
            for filetype in ("input", "output", "template"):
                allowoption = f"allowrecursive{filetype}"
                if options.multifilestyle == "onefile" and getattr(
                    options, allowoption, True
                ):
                    setattr(options, allowoption, False)

        if (
            self.usetemplates
            and options.template
            and self.isarchive(options.template, "template")
        ):
            self.templatearchive = self.openarchive(options.template, "template")

        if options.output and self.isarchive(options.output, "output"):
            self.outputarchive = self.openarchive(options.output, "output", mode="w")
        return super().recursiveprocess(options)

    def processfile(
        self, fileprocessor, options, fullinputpath, fulloutputpath, fulltemplatepath
    ):
        """Run an individual conversion."""
        if options.timestamp and _output_is_newer(fullinputpath, fulloutputpath):
            return False

        if self.isarchive(options.output, "output"):
            inputfile = self.openinputfile(options, fullinputpath)
            # TODO: handle writing back to same archive as input/template
            templatefile = self.opentemplatefile(options, fulltemplatepath)
            outputfile = self.openoutputfile(options, fulloutputpath)
            passthroughoptions = self.getpassthroughoptions(options)
            if fileprocessor(inputfile, outputfile, templatefile, **passthroughoptions):
                if not outputfile.isatty():
                    outputfile.close()
                return True
            if fulloutputpath and os.path.isfile(fulloutputpath):
                outputfile.close()
                os.unlink(fulloutputpath)
            return False
        return super().processfile(
            fileprocessor, options, fullinputpath, fulloutputpath, fulltemplatepath
        )


def _output_is_newer(input_path, output_path):
    """
    Check if input_path was not modified since output_path was generated,
    used to avoid needless regeneration of output.
    """
    if not input_path or not output_path:
        return False

    if not os.path.exists(input_path) or not os.path.exists(output_path):
        return False

    input_mtime = os.path.getmtime(input_path)
    output_mtime = os.path.getmtime(output_path)

    return output_mtime > input_mtime


def should_output_store(store, threshold):
    """
    Check if the percent of translated source words more than or equal to
    the given threshold.
    """
    if not threshold:
        return True

    units = [unit for unit in store.units if unit.istranslatable()]
    translated = [unit for unit in units if unit.istranslated()]
    wordcounts = {unit.getid(): pocount.wordsinunit(unit) for unit in units}

    def sourcewords(elementlist):
        return sum(wordcounts[unit.getid()][0] for unit in elementlist)

    translated_count = sourcewords(translated)
    total_count = sourcewords(units)
    percent = translated_count * 100 / total_count

    return percent >= threshold


class DocpathMerger:
    """
    Mixin class for merging translations using docpath-based matching.

    This mixin provides common functionality for converters that need to
    create PO files from existing translations by matching translation units
    based on their logical document path (docpath) rather than source text.
    """

    def merge_stores_by_docpath(
        self,
        inputfile,
        templatefile,
        outputstore,
        input_store_class,
        template_store_class=None,
        filter_header=False,
        process_unit_callback=None,
    ) -> None:
        """
        Merge translation from inputfile with source from templatefile using docpath matching.

        :param inputfile: File handle for the translated file (target language)
        :param templatefile: File handle for the source file (source language)
        :param outputstore: PO store to add units to
        :param input_store_class: Class to parse the input file
        :param template_store_class: Class to parse the template file (defaults to input_store_class)
        :param filter_header: Whether to skip units where isheader() returns True
        :param process_unit_callback: Optional callback to process each unit (unit, po_unit) -> None
        """
        if template_store_class is None:
            template_store_class = input_store_class

        # Parse both files - note that storage classes use 'inputfile' as parameter name
        templateparser = template_store_class(inputfile=templatefile)  # Source language
        inputparser = input_store_class(inputfile=inputfile)  # Translated language

        # Build a docpath index for the input (translated) file
        input_index = {}
        for unit in inputparser.units:
            if filter_header and hasattr(unit, "isheader") and unit.isheader():
                continue
            docpath = unit.getdocpath()
            if docpath:
                input_index[docpath] = unit

        # Iterate through template units and match with input by docpath
        for templateunit in templateparser.units:
            if (
                filter_header
                and hasattr(templateunit, "isheader")
                and templateunit.isheader()
            ):
                continue

            docpath = templateunit.getdocpath()
            storeunit = outputstore.addsourceunit(templateunit.source)
            storeunit.addlocations(templateunit.getlocations())

            # Set target from matching input unit if found
            if docpath and docpath in input_index:
                inputunit = input_index[docpath]
                storeunit.target = inputunit.source

            # Allow subclass-specific processing
            if process_unit_callback:
                process_unit_callback(templateunit, storeunit)


def main(argv=None) -> None:
    parser = ArchiveConvertOptionParser({}, description=__doc__)
    parser.run(argv)
