#! /usr/bin/env python3

# flo-update-tex-files --- Update LaTeX files and related files based upon
#                          templates
# Copyright (c) 2005, 2015, 2018 Florent Rougon
#
# 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; version 2 dated June, 1991.
#
# 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; see the file COPYING. If not, write to the
# Free Software Foundation, Inc., 51 Franklin St, Fifth Floor,
# Boston, MA  02110-1301 USA.

import sys
import os
import locale
import argparse
import re
import subprocess
import collections

progname = os.path.basename(sys.argv[0])

templateMakefiles = {
    "LaTeX": os.path.join(os.environ["HOME"], "TeX", "textes",
                          "GnuPG-key", "Makefile"),
    "LaTeX with reduc":
    os.path.join(os.environ["HOME"],
                 "divers", "enseignement",
                 "ressources", "collège", "6e",
                 "interro aires", "Makefile"),
    "MetaPost": os.path.join(os.environ["HOME"], "TeX", "Elvire",
                             "feuilles écriture", "Makefile")
    }

# Regexps for parsing Makefiles
all_line_cre = re.compile(r"all:.*")
src_basename_line_cre = re.compile(r"(SRC_BASE_NAME *:?= *)(.*)")
tex_runs_line_cre = re.compile(r"(TEX_RUNS *:?= *)(\d+)")
dup_pages_in_reduc_line_cre = re.compile(
    r"(DUPLICATE_PAGES_IN_REDUC *:?= *)([^#]+)(#.*)?")
nonStandardStuff_cre = re.compile(r"index|xindy|bibtex|biblio|p(s|df)tricks",
                                  re.IGNORECASE)
# For MetaPost-related Makefiles
mpBaseName_cre = re.compile(r"(MP_BASE_NAME *:?= *)(.*)")
mpStem_cre = re.compile(r"(MP_STEM *:?= *)(.*)")
mpNbSchemas_cre = re.compile(r"(NUMBER_OF_SCHEMAS *:?= *)(\d+)")

# Regexps for parsing LaTeX files
doc_class_cre = re.compile(r"%* *\\documentclass *\[")
microtype_cre = re.compile(r"""(?P<before_options>
                                 %*\ *
                                 \\usepackage\ *
                               )

                               (
                                 \[
                                 (?P<options> [^][]*)
                                 \]
                               )?

                               (?P<after_options>
                                 \ *
                                 \{microtype\}.*
                               )""",
                           re.VERBOSE)
uncommented_babel_re = r" *\\usepackage\b.*\{babel\}"
uncommented_babel_cre = re.compile(uncommented_babel_re)
babel_cre = re.compile(r"%*" + uncommented_babel_re)
babel_setup_cre = re.compile(r"%* *\\frenchbsetup\{")

# Regexps for parsing MetaPost files. 'filenametemplate' is obsolete in
# MetaPost.
mpObsoleteFileNameTemplate_cre = re.compile(
    r'''(\ * filenametemplate\ +")
        (.*)                           # stem
        (%c\.mps";.*)''',
    re.VERBOSE)

# 'outputtemplate' is the correct thing to use nowadays instead of
# 'filenametemplate'.
mpOutputTemplate_cre = re.compile(
    r'''(\ * outputtemplate \ + := \ +")
        (?P<template> [^"]* ");.*''',
    re.VERBOSE)

# Despite the name, this is only used to describe which interesting things and
# how to extract them from a particular (different for TeX-Makefiles and for
# MetaPost-Makfiles).
ThingsToLookFor = collections.namedtuple(
    "ThingsToLookFor", ["id", "desc", "cregexp", "extractFunc", "rewriteFunc",
                        "mandatory"])

NOTOUCH_FLAG_FILE = "flo-update-tex-files.dont-touch-this-dir"


class RequestedProgramExit(Exception):
    pass


def errExit(msg):
    # This way, I think we honour finally clauses of try... finally statements
    # if the exception isn't caught, or is caught at the very top-level.
    raise RequestedProgramExit(msg)

Makefile_reducLine_cre = re.compile(r".* (\b|[-_]) r[eé]duc \b ", re.VERBOSE)

def extractInfoFromMakefile(oldMakefile, thingsToLookFor):
    d = {"hasReduc": False}
    res = { t.id: None for t in thingsToLookFor }

    with open(oldMakefile, "r", encoding="utf-8") as f:
        for line in f:
            for t in thingsToLookFor:
                if res[t.id] is None:
                    mo = t.cregexp.match(line)
                    if mo:
                        res[t.id] = t.extractFunc(mo, line)

            if Makefile_reducLine_cre.match(line):
                d["hasReduc"] = True

    for t in thingsToLookFor:
        if t.mandatory and res[t.id] is None:
            errExit("couldn't find {} in {!r}. Aborting.".format(
                t.desc, oldMakefile))

    return d, res

# Référence pour le Makefile MetaPost : ~/TeX/Elvire/feuilles écriture/Makefile
# Référence pour le Makefile LaTeX : ~/TeX/textes/GnuPG-key/Makefile
def writeNewMakefile(newfile_name, templatefile_name, thingsToLookFor,
                     flags, valuesFromOldFile):
    with open(templatefile_name, "r", encoding="utf-8") as template_file, \
            open(newfile_name, "w", encoding="utf-8") as new_file:
        found = { t.id: False for t in thingsToLookFor }

        for line in template_file:
            to_write = line     # default

            for t in thingsToLookFor:
                if not found[t.id]:
                    mo = t.cregexp.match(line)
                    if mo:
                        found[t.id] = True

                        if flags["hasReduc"] and t.id == "allLine":
                            # Special case, not very pretty...
                            to_write = "all: pdf # reduc.pdf\n"
                        else:
                            to_write = t.rewriteFunc(mo,
                                                     valuesFromOldFile[t.id])
                        break

            new_file.write(to_write)


def filesIdentical(file1, file2):
    """Return True if the specified files have identical contents."""
    # import pprint
    # print("XXX")
    # pprint.pprint(os.listdir(os.path.dirname(file1)))
    cmpArgs = ["cmp", "--quiet", file1, file2]
    retcode = subprocess.call(cmpArgs)

    if retcode == 0:
        return True
    elif retcode == 1:
        return False
    elif retcode > 1:
        errExit("error while running {!r}; aborting.".format(' '.join(cmpArgs)))
    else:
        errExit("diff process for {!r} was killed by signal {}, aborting."
                .format(' '.join(cmpArgs), -retcode))


def smartRename(source, target):
    if filesIdentical(source, target):
        os.unlink(source)
    else:
        os.rename(source, target)


def showDiff(old, new, usePager=True):
    if filesIdentical(old, new):
        # print("{!r} and {!r} are identical.".format(old, new))
        return True

    diff_args = ["diff", "-u", old, new]
    stdout = subprocess.PIPE if usePager else None

    with subprocess.Popen(diff_args, stdout=stdout) as proc:
        if usePager:
            subprocess.check_call(["pager"], stdin=proc.stdout)

    if proc.returncode > 1:
        print("Warning: diff process terminated with exit status {}".format(
                proc.returncode), file=sys.stderr)
    elif proc.returncode < 0:
        print("Warning: diff process was killed by signal {}".format(
                -proc.returncode), file=sys.stderr)

    return False


def removeLaTeXPkgOption(options, option):
    assert options.startswith('[') and options.endswith(']'), options
    l = options[1:-1].split(',')
    try:
        l.remove(option)
    except ValueError:
        pass

    opts = ','.join(l)
    return "[{}]".format(opts) if opts else ""


def buildFixedMicrotypeLine(line, matchObject, hasUncommentedBabel):
    # Ancien code qui préservait les options passées à microtype, à ceci près
    # qu'il virait l'option 'babel' si le package babel n'était pas utilisé :
    #
    # if hasUncommentedBabel:     # babel.sty is actually used
    #     return line
    # else:                       # Remove the "babel" option
    #     options = '[' + matchObject.group("options") + ']'

    #     # The .* at the end of 'microtype_cre' didn't eat the \n at the end of
    #     # 'line'.
    #     return (matchObject.group("before_options") +
    #             removeLaTeXPkgOption(options, "babel") +
    #             matchObject.group("after_options") + '\n')

    if hasUncommentedBabel:     # babel.sty is actually used
        return r"\usepackage[final,babel]{microtype}" + '\n'
    else:
        return r"\usepackage[final]{microtype}" + '\n'


def tmpPathFor(path):
    return "{}.{}-new".format(path, progname)


def fixTexFile(tmpFiles, inputFilePath, *, dryRun=False, onlyWriteTmpFile=False):
    hasBabel = False
    hasUncommentedBabel = False
    hasBabelSetup = False

    # Read-only pass
    with open(inputFilePath, "r", encoding="utf-8") as ifile:
        for line in ifile:
            if babel_cre.match(line):
                hasBabel = True
                if uncommented_babel_cre.match(line):
                    hasUncommentedBabel = True
            elif babel_setup_cre.match(line):
                hasBabelSetup = True

    tmpFileName = tmpPathFor(inputFilePath)
    fixedMicrotypeLine = None
    microtypeMoved = False
    foundBabel = False

    tmpFiles.append(tmpFileName) # for our caller

    try:
        with open(inputFilePath, "r", encoding="utf-8") as ifile, \
             open(tmpFileName, "w", encoding="utf-8") as tmpfile:
            for line in ifile:
                if doc_class_cre.match(line):
                    tmpfile.write(line.replace("frenchb,french", "french")
                                      .replace(",*FloTeXDriver*", ""))
                elif microtype_cre.match(line):
                    mo = microtype_cre.match(line)
                    assert mo is not None, line

                    fixedMicrotypeLine = buildFixedMicrotypeLine(
                        line, mo, hasUncommentedBabel)

                    if foundBabel or not hasBabel:
                        # Don't move the \usepackage ... {microtype} in this
                        # case
                        tmpfile.write(fixedMicrotypeLine)
                elif babel_cre.match(line):
                    tmpfile.write(line)
                    foundBabel = True

                    if (fixedMicrotypeLine and not hasBabelSetup and
                        not microtypeMoved):
                        # print("early move", flush=True)
                        tmpfile.write(fixedMicrotypeLine)
                        microtypeMoved = True
                elif (foundBabel and babel_setup_cre.match(line) and
                      fixedMicrotypeLine and not microtypeMoved):
                    # print("late move", flush=True)
                    tmpfile.write(line + fixedMicrotypeLine)
                    microtypeMoved = True
                else:
                    tmpfile.write(line)

        changed = not showDiff(inputFilePath, tmpFileName, usePager=False)
        print(flush=True)

        if changed and not dryRun and not onlyWriteTmpFile:
            smartRename(tmpFileName, inputFilePath)

        return changed          # the 'finally' clause below is still honoured
    finally:
        if os.path.isfile(tmpFileName) and not onlyWriteTmpFile:
            os.unlink(tmpFileName)


def fixMetaPostFile(tmpFiles, inputFilePath, mpStemInMakefile, *, dryRun=False,
                    onlyWriteTmpFile=False):
    foundUpToDateOutputTemplate = False
    rest = None

    with open(inputFilePath, "r", encoding="utf-8") as f:
        for line in f:
            mo = mpObsoleteFileNameTemplate_cre.match(line)
            if mo:
                rest = f.read() # what follows the 'filenametemplate' line
                break

            mo2 = mpOutputTemplate_cre.match(line)
            if mo2:
                foundUpToDateOutputTemplate = True
                break
        else:
            # no 'filenametemplate' nor 'outputtemplate' declaration found
            f.seek(0)
            rest = f.read()     # all file contents

    if foundUpToDateOutputTemplate:
        # We have an 'outputtemplate := "...";' line, it should be fine.
        return False            # no change done to the .mp file

    assert rest is not None

    # Add an 'outputtemplate' declaration to the .mp file
    tmpFileName = tmpPathFor(inputFilePath)
    tmpFiles.append(tmpFileName) # for our caller
    try:
        with open(tmpFileName, "w", encoding="utf-8") as tmpfile:
            tmpfile.write(
                'outputtemplate := "{}%c.mps";\n\n'.format(mpStemInMakefile) +
                rest)

        changed = not showDiff(inputFilePath, tmpFileName, usePager=False)
        print(flush=True)

        if changed and not dryRun and not onlyWriteTmpFile:
            smartRename(tmpFileName, inputFilePath)

        return changed          # the 'finally' clause below is still honoured
    finally:
        if os.path.isfile(tmpFileName) and not onlyWriteTmpFile:
            os.unlink(tmpFileName)


def analyseMakefile(inputFilePath):
    with open(inputFilePath, "r", encoding="utf-8") as f:
        makefileType = "LaTeX"
        containsNonStandardStuff = False

        for line in f:
            if re.search(r"\tmpost ", line):
                makefileType = "MetaPost"
            if nonStandardStuff_cre.search(line):
                containsNonStandardStuff = True

    thingsToLookFor = [
        ThingsToLookFor("allLine", "the 'all: ...' line", all_line_cre,
                        lambda mo, line: line, lambda mo, value: value, True),
        ThingsToLookFor("srcBasename", "the 'SRC_BASE_NAME := ...' line",
                        src_basename_line_cre, lambda mo, line: mo.group(2),
                        lambda mo, value: "{}{}\n".format(mo.group(1), value),
                        True),
        ThingsToLookFor("TeXRuns", "the 'TEX_RUNS := ...' line",
                        tex_runs_line_cre, lambda mo, line: int(mo.group(2)),
                        lambda mo, value: "{}{}\n".format(mo.group(1), value),
                        True),
        ThingsToLookFor("DupPagesInReduc",
                        "the 'DUPLICATE_PAGES_IN_REDUC := ...' line",
                        dup_pages_in_reduc_line_cre,
                        lambda mo, line: mo.group(2).strip(),
                        lambda mo, value: "{}{}{}\n".format(mo.group(1),
                                                            value or "true",
                                                            " " + mo.group(3)),
                        False) ]

    if makefileType == "MetaPost":
        thingsToLookFor.extend([
                ThingsToLookFor(
                    "mpBasename", "the 'MP_BASE_NAME := ...' line",
                    mpBaseName_cre, lambda mo, line: mo.group(2),
                    lambda mo, value: "{}{}\n".format(mo.group(1), value),
                    True),
                ThingsToLookFor(
                    "mpStem", "the 'MP_STEM := ...' line",
                    mpStem_cre, lambda mo, line: mo.group(2),
                    lambda mo, value: "{}{}\n".format(mo.group(1), value),
                    True),
                ThingsToLookFor(
                    "mpNbSchemas", "the 'NUMBER_OF_SCHEMAS := ...' line",
                    mpNbSchemas_cre, lambda mo, line: int(mo.group(2)),
                    lambda mo, value: "{}{}\n".format(mo.group(1), value),
                    True)])

    return (makefileType, thingsToLookFor, containsNonStandardStuff)


def automaticFixes(tmpFiles, dir_, *, dryRun=False, onlyWriteTmpFiles=False):
    different = set()
    paths = {}

    # The Makefile inside 'dir_'
    oldMakefile = os.path.join(dir_, "Makefile")
    paths["Makefile"] = oldMakefile
    if not os.path.isfile(oldMakefile):
        return paths, False, False

    makefileType, thingsToLookFor, makefileContainsNonStandardStuff = \
        analyseMakefile(oldMakefile)
    flags, extractionRes = extractInfoFromMakefile(oldMakefile, thingsToLookFor)

    if makefileType == "LaTeX" and flags["hasReduc"]:
        makefileType = "LaTeX with reduc"

    templateMakefile = templateMakefiles[makefileType]
    newMakefile = tmpPathFor(oldMakefile)
    tmpFiles.append(newMakefile) # for our caller
    writeNewMakefile(newMakefile, templateMakefile, thingsToLookFor, flags,
                     extractionRes)
    paths["Makefile"] = oldMakefile

    if not (dryRun or onlyWriteTmpFiles):
        smartRename(newMakefile, oldMakefile)

    if not filesIdentical(oldMakefile, newMakefile):
        different.add("Makefile")

    # The .tex file
    texFilePath = os.path.join(dir_, extractionRes["srcBasename"] + ".tex")
    paths["TeX file"] = texFilePath
    texFileDifferent = fixTexFile(tmpFiles, texFilePath, dryRun=dryRun,
                                  onlyWriteTmpFile=onlyWriteTmpFiles)

    if texFileDifferent:
        different.add("TeX file")

    # The .mp file, if any
    if makefileType == "MetaPost":
        mpFilePath = os.path.join(dir_, extractionRes["mpBasename"] + ".mp")
        paths["MetaPost file"] = mpFilePath
        mpFileDifferent = fixMetaPostFile(
            tmpFiles, mpFilePath, extractionRes["mpStem"], dryRun=dryRun,
            onlyWriteTmpFile=onlyWriteTmpFiles)

        if mpFileDifferent:
            different.add("MetaPost file")

    # Non-empty 'different' means that at least one of the files in 'paths'
    # would be changed if dryRun were False
    return paths, different, makefileContainsNonStandardStuff


# Could use an enum...
automaticFileTypes = ["TeX file",
                      "MetaPost file",
                      "Makefile"]

def commitAll(pathDict):
    for baseFilePath in pathDict.values():
        tmpFile = tmpPathFor(baseFilePath)

        if os.path.isfile(tmpFile):
            smartRename(tmpFile, baseFilePath)


def updateFilesNonRecurs_inner(tmpFiles, dir_):
    paths, different, makefileContainsNonStandardStuff = automaticFixes(
        tmpFiles, dir_, onlyWriteTmpFiles=True)

    if not os.path.isfile(paths["Makefile"]): # redundant work...
        return

    if not different:
        print("No change to do in this directory")
        return

    if params.automatic:
        if makefileContainsNonStandardStuff:
            print("{!r} seems to contain non-standard things. Courageously "
                  "skipping.".format(paths["Makefile"]))
            return

        subprocess.check_call(["make", "-C", dir_, "clean"],
                              stderr=subprocess.STDOUT)
        commitAll(paths)
        subprocess.check_call(["make", "-C", dir_], stderr=subprocess.STDOUT)
    else:
        if makefileContainsNonStandardStuff:
            print("WARNING! {!r} seems to contain non-standard things!\n"
                  "Press Enter to continue.".format(paths["Makefile"]))
            input()

        print("Changed: {}".format(", ".join(different)))

        question = """\nAvailable options are:
    a   apply all changes
    n   no   [do nothing]
    ted show a diff between the old TeX file and the new one
    mpd show a diff between the old MetaPost file and the new one
    mad show a diff between the old Makefile and the new one
    o   show the old Makefile
    c   run 'make clean', apply all changes and open the .tex file
Your choice? [a/n/ted/mpd/mad/o/C] """

        while True:
            reply = input(question)
            if reply.lower() == "a":
                commitAll(paths)
                break
            elif reply.lower() == "n":
                break
            elif reply.lower() in ("ted", "mpd", "mad"):
                d = {"ted": "TeX file",
                     "mpd": "MetaPost file",
                     "mad": "Makefile"}
                baseFilePath = paths[d[reply.lower()]]
                showDiff(baseFilePath, tmpPathFor(baseFilePath))
            elif reply.lower() == "o":
                subprocess.check_call(["pager", paths["Makefile"]])
            elif reply.lower() in ("", "c"):
                subprocess.check_call(["make", "-C", dir_, "clean"])
                commitAll(paths)
                subprocess.check_call(["emacsclient", "-n", paths["TeX file"]])
                break


def updateFilesNonRecurs(dir_):
    # Will be modified in-place by updateFilesNonRecurs_inner() so that changes
    # take effect even if updateFilesNonRecurs_inner() is aborted due to an
    # exception.
    tmpFiles = []

    try:
        updateFilesNonRecurs_inner(tmpFiles, dir_)
    finally:
        for f in tmpFiles:
            if os.path.isfile(f):
                os.unlink(f)


def osWalkDumbErrorHandling(exception):
    # This directory is typically removed by 'make clean' with my TeX
    # Makefiles, which causes problems when os.walk() had already planned
    # to go inside it.
    if (isinstance(exception, FileNotFoundError) and
        os.path.basename(exception.filename) == "auctex-auto"):
        return

    raise exception


def updateFiles(dir_, recursive=False):
    if recursive:
        for root, dirs, files in os.walk(dir_, onerror=osWalkDumbErrorHandling):
            print("*********************************************************\n"
                  + root + "\n" +
                  "*********************************************************")

            # Determine which subdirs to prune from the recursive traversal
            i = 0
            while i < len(dirs):
                subdir = dirs[i]
                flagFile = os.path.join(root, subdir, NOTOUCH_FLAG_FILE)
                if os.path.isfile(flagFile):
                    print("'{}' (and all of its subdirectories) will be "
                          "ignored because it contains '{}'".format(
                              os.path.join(root, subdir), NOTOUCH_FLAG_FILE))
                    del dirs[i] # don't recurse into this one
                else:
                    i += 1

            # Let's deal with 'root' now
            flagFile = os.path.join(root, NOTOUCH_FLAG_FILE)
            if os.path.isfile(flagFile):
                print("Not updating '{}' because of the presence of '{}'"
                      .format(root, NOTOUCH_FLAG_FILE))
            else:
                updateFilesNonRecurs(root)
    else:
        flagFile = os.path.join(dir_, NOTOUCH_FLAG_FILE)

        if os.path.isfile(flagFile):
            print("Not updating '{}' because of the presence of '{}'".format(
                dir_, NOTOUCH_FLAG_FILE))
        else:
            updateFilesNonRecurs(dir_)


def processCommandLine():
    params = argparse.Namespace()

    parser = argparse.ArgumentParser(
        usage="""\
%(prog)s [OPTION ...] [DIR...]
Update (La)TeX and related files (Makefiles, etc.).""",
        description="""\
Apply a number of changes to TeX-related files (Makefiles, .tex and
.mp files).""",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        # I want --help but not -h (it might be useful for something else)
        add_help=False)

    parser.add_argument('-r', '--recursive', action='store_true', help="""\
      process the DIR directories recursively""")
    parser.add_argument('--automatic', action='store_true', help="""\
      enable non-interactive mode (automatically update files)""")
    parser.add_argument('dirs', metavar="DIR", nargs='*',
                        help="""directory to process""")
    parser.add_argument('--help', action="help",
                        help="display this message and exit")

    params = parser.parse_args(namespace=params)

    return params


def main():
    global params

    locale.setlocale(locale.LC_ALL, '')
    params = processCommandLine()

    if params.dirs:
        for d in params.dirs:
            updateFiles(d, recursive=params.recursive)
    else:
        updateFiles(os.getcwd(), recursive=params.recursive)

    sys.exit(0)

if __name__ == "__main__": main()
