#!/usr/bin/env python

# default options; feel free to change!
defaultCompiler = "pdflatex"
defaultArguments = "-synctex=1 -interaction=nonstopmode"
defaultSpeechSetting = "never"

#
# texliveonfly.py (formerly lualatexonfly.py) - "Downloading on the fly"
#     (similar to miktex) for texlive.
#
# Given a .tex file, runs lualatex (by default) repeatedly, using error messages
#     to install missing packages.
#
#
# Version 1.2 ; October 4, 2011
#
# Written on Ubuntu 10.04 with TexLive 2011
# Python 2.6+ or 3
# Should work on Linux and OS X
#
# Copyright (C) 2011 Saitulaa Naranong
#
# 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 3 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/copyleft/gpl.html>.

import re, subprocess, os, time,  optparse, sys, shlex

scriptName = os.path.basename(__file__)     #the name of this script file
py3 = sys.version_info[0]  >= 3

#functions to support python3's usage of bytes in some places where 2 uses strings
tobytesifpy3 = lambda s = None  : s.encode() if py3 and s != None else s
frombytesifpy3 = lambda b = None : b.decode("UTF-8") if py3 and b != None else b

#version of Popen.communicate that always takes and returns strings
#regardless of py version
def communicateStr ( process,  s = None ):
    (a,b) = process.communicate( tobytesifpy3(s) )
    return ( frombytesifpy3(a), frombytesifpy3(b) )

subprocess.Popen.communicateStr = communicateStr

#global variables (necessary in py2; for py3 should use nonlocal)
installation_initialized = False
installing = False

def generateSudoer(this_terminal_only = False,  tempDirectory = os.path.join(os.getenv("HOME"), ".texliveonfly") ):
    lockfilePath = os.path.join(tempDirectory,  "newterminal_lock")
    #NOTE: double-escaping \\ is neccessary for a slash to appear in the bash command
    # in particular, double quotations in the command need to be written \\"
    def spawnInNewTerminal(bashCommand):
        #makes sure the temp directory exists
        try:
            os.mkdir(tempDirectory)
        except OSError:
            print("\n" + scriptName + ": Our temp directory " + tempDirectory +  " already exists; good.")

        #creates lock file
        lockfile = open(lockfilePath, 'w')
        lockfile.write( "Terminal privilege escalator running.")
        lockfile.close()

        #adds intro and line to remove lock
        bashCommand = '''echo \\"The graphical privilege escalator failed for some reason; we'll try asking for your administrator password here instead.\\n{0}\\n\\";{1}; rm \\"{2}\\"'''.format("-"*18,  bashCommand, lockfilePath)

        #runs the bash command in a new terminal
        try:
            subprocess.Popen ( ['x-terminal-emulator', '-e',  'sh -c "{0}"'.format(bashCommand) ]  )
        except OSError:
            try:
                subprocess.Popen ( ['xterm', '-e',  'sh -c "{0}"'.format(bashCommand) ]  )
            except OSError:
                os.remove(lockfilePath)
                raise

        #doesn't let us proceed until the lock file has been removed by the bash command
        while os.path.exists(lockfilePath):
            time.sleep(0.1)

    def runSudoCommand(bashCommand):
        if this_terminal_only:
            process = subprocess.Popen( ['sudo'] + shlex.split(bashCommand) )
            process.wait()
        elif os.name == "mac":
            process = subprocess.Popen(['osascript'], stdin=subprocess.PIPE )
            process.communicateStr( '''do shell script "{0}" with administrator privileges'''.format(bashCommand) )
        else:
            #raises OSError if neither exist
            try:
                process = subprocess.Popen( ['gksudo', bashCommand] )
            except OSError:
                process = subprocess.Popen( ['kdesudo', bashCommand] )

            process.wait()

    # First tries one-liner graphical/terminal sudo, then opens extended command in new terminal
    # raises OSError if both do
    def attemptSudo(oneLiner, newTerminalCommand = ""):
        try:
            runSudoCommand(oneLiner)
        except OSError:
            if this_terminal_only:
                print("The sudo command has failed and we can't launch any more terminals.")
                raise
            else:
                print("Default graphical priviledge escalator has failed for some reason.")
                print("A new terminal will open and you may be prompted for your sudo password.")
                spawnInNewTerminal(newTerminalCommand)

    return attemptSudo

#speech_setting = "never" prioritized over all others: "always", "install", "fail"
def generateSpeakers(speech_setting):
    speech_setting = speech_setting.lower()
    doNothing = lambda x, failure = None : None

    #most general inputs, always speaks
    generalSpeaker = lambda expression,  failure = False : speakerFunc(expression)

    if "never" in speech_setting:
        return (doNothing, doNothing)

    try:
        if os.name == "mac":
            speaker = subprocess.Popen(['say'], stdin=subprocess.PIPE )
        else:
            speaker = subprocess.Popen(['espeak'], stdin=subprocess.PIPE )
    except:
        return (doNothing, doNothing)

    def speakerFunc(expression):
        if not expression.endswith("\n"):
            expression += "\n"
        try:
            speaker.stdin.write(tobytesifpy3(expression))
            speaker.stdin.flush()
        except: #very tolerant of errors here
            print("An error has occurred when using the speech synthesizer.")

    #if this is called, we're definitely installing.
    def installationSpeaker(expression):
        global installing
        installing = True   #permanantly sets installing (for the endSpeaker)
        if "install" in speech_setting:
            speakerFunc(expression)

    def endSpeaker(expression,  failure = False):
        if installing and "install" in speech_setting or failure and "fail" in speech_setting:
            speakerFunc(expression)

    if "always" in speech_setting:
        return (generalSpeaker, generalSpeaker)
    else:
        return (installationSpeaker, endSpeaker)

#generates speaker for installing packages and an exit function
def generateSpeakerFuncs(speech_setting):
    (installspeaker,  exitspeaker) = generateSpeakers(speech_setting)

    def exiter(code = 0):
        exitspeaker("Compilation{0}successful.".format(", un" if code != 0 else " "),  failure = code != 0 )
        sys.exit(code)

    return (installspeaker, exiter)

def generateTLMGRFuncs(tlmgr, speaker, sudoFunc):
    #checks that tlmgr is installed, raises OSError otherwise
    #also checks whether we need to escalate permissions, using fake remove command
    process = subprocess.Popen( [ tlmgr,  "remove" ], stdin=subprocess.PIPE, stdout = subprocess.PIPE,  stderr=subprocess.PIPE  )
    (tlmgr_out,  tlmgr_err) = process.communicateStr()

    #does our default user have update permissions?
    default_permission = "don't have permission" not in tlmgr_err

    #always call on first update; updates tlmgr and checks permissions
    def initializeInstallation():
        updateInfo = "Updating tlmgr prior to installing packages\n(this is necessary to avoid complaints from itself)."
        print( scriptName + ": " + updateInfo)

        if default_permission:
            process = subprocess.Popen( [tlmgr,  "update",  "--self" ] )
            process.wait()
        else:
            print( "\n{0}: Default user doesn't have permission to modify the TeX Live distribution; upgrading to superuser for installation mode.\n".format(scriptName) )
            basicCommand = ''''{0}' update --self'''.format(tlmgr)
            sudoFunc( basicCommand, '''echo \\"This is {0}'s 'install packages on the fly' feature.\\n\\n{1}\\n\\" ; sudo {2}'''.format(scriptName, updateInfo, basicCommand ) )

    def installPackages(packages):
        if len(packages) == 0:
            return

        global installation_initialized
        if not installation_initialized:
            initializeInstallation()
            installation_initialized = True

        packagesString = " ".join(packages)
        print("{0}: Attempting to install LaTex package(s): {1}".format( scriptName, packagesString ) )

        if default_permission:
            process = subprocess.Popen( [ tlmgr,  "install"] + packages , stdin=subprocess.PIPE )
            process.wait()
        else:
            basicCommand = ''''{0}' install {1}'''.format(tlmgr,  packagesString)
            bashCommand='''echo \\"This is {0}'s 'install packages on the fly' feature.\\n\\nAttempting to install LaTeX package(s): {1} \\"
echo \\"(Some of them might not be real.)\\n\\"
sudo {2}'''.format(scriptName, packagesString, basicCommand)

            sudoFunc(basicCommand, bashCommand)

    #strictmatch requires an entire /file match in the search results
    def getSearchResults(preamble, term, strictMatch):
        fontOrFile =  "font" if "font" in preamble else "file"
        speaker("Searching for missing {0}: {1} ".format(fontOrFile, term))
        print( "{0}: Searching repositories for missing {1} {2}".format(scriptName, fontOrFile,  term) )

        process = subprocess.Popen([ tlmgr, "search", "--global", "--file", term], stdin=subprocess.PIPE, stdout = subprocess.PIPE, stderr=subprocess.PIPE )
        ( output ,  stderrdata ) = process.communicateStr()
        outList = output.split("\n")

        results = ["latex"]    #latex 'result' for removal later

        for line in outList:
            line = line.strip()
            if line.startswith(preamble) and (not strictMatch or line.endswith("/" + term)):
                #filters out the package in:
                #   texmf-dist/.../package/file
                #and adds it to packages
                results.append(line.split("/")[-2].strip())
                results.append(line.split("/")[-3].strip()) #occasionally the package is one more slash before

        results = list(set(results))    #removes duplicates
        results.remove("latex")     #removes most common fake result

        if len(results) == 0:
            speaker("File not found.")
            print("{0}: No results found for {1}".format( scriptName, term ) )
        else:
            speaker("Installing.")

        return results

    def searchFilePackage(file):
        return getSearchResults("texmf-dist/", file, True)

    def searchFontPackage(font):
        font = re.sub(r"\((.*)\)", "", font)    #gets rid of parentheses
        results = getSearchResults("texmf-dist/fonts/", font , False)

        #allow for possibility of lowercase
        if len(results) == 0:
            return [] if font.islower() else searchFontPackage(font.lower())
        else:
            return results

    def searchAndInstall(searcher,  entry):
        installPackages(searcher(entry))
        return entry    #returns the entry just installed

    return ( lambda entry : searchAndInstall(searchFilePackage,  entry),  lambda entry : searchAndInstall(searchFontPackage,  entry) )

def generateCompiler(compiler, arguments, texDoc, exiter):
    def compileTexDoc():
        try:
            process = subprocess.Popen( [compiler] + shlex.split(arguments) + [texDoc], stdin=sys.stdin, stdout = subprocess.PIPE )
            return readFromProcess(process)
        except OSError:
            print( "{0}: Unable to start {1}; are you sure it is installed?{2}".format(scriptName, compiler,
                "  \n\n(Or run " + scriptName + " --help for info on how to choose a different compiler.)" if compiler == defaultCompiler else "" )
                )
            exiter(1)

    def readFromProcess(process):
        getProcessLine = lambda : frombytesifpy3(process.stdout.readline())

        output = ""
        line = getProcessLine()
        while line != '':
            output += line
            sys.stdout.write(line)
            line = getProcessLine()

        returnCode = None
        while returnCode == None:
            returnCode = process.poll()

        return (output, returnCode)

    return compileTexDoc

### MAIN PROGRAM ###

if __name__ == '__main__':
    # Parse command line
    parser = optparse.OptionParser(
        usage="\n\n\t%prog [options] file.tex\n\nUse option --help for more info.",
        description = 'This program downloads TeX Live packages "on the fly" while compiling .tex documents.  ' +
            'Some of its default options can be directly changed in {0}.  For example, the default compiler can be edited on line 4.'.format(scriptName) ,
        version='1.2',
        epilog = 'Copyright (C) 2011 Saitulaa Naranong.  This program comes with ABSOLUTELY NO WARRANTY; see the GNU General Public License v3 for more info.' ,
        conflict_handler='resolve'
    )

    parser.add_option('-h', '--help', action='help', help='print this help text and exit')
    parser.add_option('-c', '--compiler', dest='compiler', metavar='COMPILER',
        help='your LaTeX compiler; defaults to {0}'.format(defaultCompiler), default=defaultCompiler)
    parser.add_option('-a', '--arguments', dest='arguments', metavar='ARGS',
        help='arguments to pass to compiler; default is: "{0}"'.format(defaultArguments) , default=defaultArguments)
    parser.add_option('--texlive_bin', dest='texlive_bin', metavar='LOCATION',
        help='Custom location for the TeX Live bin folder', default="")
    parser.add_option('--terminal_only', action = "store_true" , dest='terminal_only', default=False,
        help="Forces us to assume we can run only in this terminal.  Permission escalators will appear here rather than graphically or in a new terminal.")
    parser.add_option('-s',  '--speech_when' , dest='speech_setting', metavar="OPTION",  default=defaultSpeechSetting ,
        help='Toggles speech-synthesized notifications (where supported).  OPTION can be "always", "never", "installing", "failed", or some combination.')
    parser.add_option('-f', '--fail_silently', action = "store_true" , dest='fail_silently',
        help="If tlmgr cannot be found, compile document anyway.", default=False)

    (options, args) = parser.parse_args()

    if len(args) == 0:
        parser.error( "{0}: You must specify a .tex file to compile.".format(scriptName) )

    texDoc = args[0]
    compiler_path = os.path.join( options.texlive_bin, options.compiler)

    (installSpeaker, exitScript) = generateSpeakerFuncs(options.speech_setting)
    compileTex = generateCompiler( compiler_path, options.arguments, texDoc, exitScript)

    #initializes tlmgr, responds if the program not found
    try:
        tlmgr_path = os.path.join(options.texlive_bin, "tlmgr")
        (installFile,  installFont) = generateTLMGRFuncs(tlmgr_path,  installSpeaker,  generateSudoer(options.terminal_only))
    except OSError:
        if options.fail_silently:
            (output, returnCode)  = compileTex()
            exitScript(returnCode)
        else:
            parser.error( "{0}: It appears {1} is not installed.  {2}".format(scriptName, tlmgr_path,
                "Are you sure you have TeX Live 2010 or later?" if tlmgr_path == "tlmgr" else "" ) )

    #loop constraints
    done = False
    previousFile = ""
    previousFontFile = ""
    previousFont =""

    #keeps running until all missing font/file errors are gone, or the same ones persist in all categories
    while not done:
        (output, returnCode)  = compileTex()

        #most reliable: searches for missing file
        filesSearch = re.findall(r"! LaTeX Error: File `([^`']*)' not found" , output) + re.findall(r"! I can't find file `([^`']*)'." , output)
        filesSearch = [ name for name in filesSearch if name != texDoc ]  #strips our .tex doc from list of files
        #next most reliable: infers filename from font error
        fontsFileSearch = [ name + ".tfm" for name in re.findall(r"! Font \\[^=]*=([^\s]*)\s", output) ]
        #brute force search for font name in files
        fontsSearch =  re.findall(r"! Font [^\n]*file\:([^\:\n]*)\:", output) + re.findall(r"! Font \\[^/]*/([^/]*)/", output)

        try:
            if len(filesSearch) > 0 and filesSearch[0] != previousFile:
                previousFile = installFile(filesSearch[0] )
            elif len(fontsFileSearch) > 0 and fontsFileSearch[0] != previousFontFile:
                previousFontFile = installFile(fontsFileSearch[0])
            elif len(fontsSearch) > 0 and fontsSearch[0] != previousFont:
                previousFont = installFont(fontsSearch[0])
            else:
                done = True
        except OSError:
            print("\n{0}: Unable to update; all privilege escalation attempts have failed!".format(scriptName) )
            print("We've already compiled the .tex document, so there's nothing else to do.\n  Exiting..")
            exitScript(returnCode)

    exitScript(returnCode)
