/*
* This groovy script converts a JFLAP jff file representing a finite automaton
* to LaTeX file depicting the automaton graphically using TikZ.
*
* Copyright (c) 2009, 2014, 2015 Andrew Mertz and William Slough
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/

// Define a few constants
scale = 1.0                 // 1 pixel in JFLAP = scale points in LaTeX
gridsize = 20.0             // gridsize is measured in pixels
turingBlank = '$\\Box$'     // the symbol to be used for a blank in a TM
blank = '$\\lambda$'        // the symbol to be used for blank otherwise
arrowWidth = 6              // width of an arrow in points
arrowLength = 9             // length of an arrow in points
arrowType = 'Stealth'       // arrow type from arrows.meta TikZ library
acceptingDistance = 2       // distance, in pt, between circles in accepting
                           // state
version = '1.2'
infilename = null
outputStream = null

// Define a function that cleans up text that could be "bad" for LaTeX to
// ingest
String fixText(String text)
{
 if(text)
 {
   text = text.replace('\\', '$\\backslash$')
   text = text.replace('~', '$\\sim$')
   text = text.replaceAll('[$%^&_#{}]', {'\\'+ it[0]})
 }
 return text
}

// Setup the command line options
// TODO: maybe add a switch for arrow type
// TODO: maybe allow different scales and grid sizes for each axis
// TODO: maybe allow user to set preview boarder
cmdLine = new CliBuilder(usage: 'jflap2tikz [options]',
                        header: "Version ${version}",
                        width: 80)

cmdLine.d(longOpt:'accepting-distance',
         args: 1,
         argName: 'distance',
         required: false,
         "Distance, in pt, between the circles of an accepting state (default is ${acceptingDistance})")
cmdLine.i(longOpt: 'input-file',
         args: 1,
         argName: 'filename',
         'Name of a JFLAP jff file representing a finite automaton, pushdown automaton, or Turing machine. If a ' +
         'file is not given standard input will be used.')
cmdLine.g(longOpt:'grid',
         args:1,
         optionalArg: true,
         argName: 'size',
         required: false,
         "Round positions so that they are on a grid. If a size is given it sets the spacing of the grid (default " +
         "is ${gridsize})")
cmdLine.h(longOpt:'help',
         required: false,
         'Show usage information and quit')
cmdLine.l(longOpt:'arrow-length',
         args:1,
         argName: 'length',
         required: false,
         "Length of arrows in points (default is ${arrowLength})")
cmdLine.o(longOpt: 'output-file',
         args: 1,
         argName: 'filename',
         'Name of a file for writing output. If this file already exists it will be overwritten.')
cmdLine.r(longOpt:'rotate',
         required: false,
         'Rotate labels along edges')
cmdLine.s(longOpt:'scale',
         args:1,
         argName:'x',
         required: false,
         "1 pixel in JFLAP = x points in LaTeX (default is ${scale})")
cmdLine.w(longOpt:'arrow-width',
         args:1,
         argName:'width',
         required: false,
         "Width of arrows in points (default is ${arrowWidth})")
cmdLine.k(longOpt:'keep-names',
         required: false,
         "Use the state names from the JFLAP file. The default is to replace the state names with names of the " +
         "form '\$q_{id}\$', where id is the unique state number. Note state names will not be sanitized and thus " +
         "may contain invalid TeX.")

// Parse the command line options
options = cmdLine.parse(args)

// If there is a parse error, quit. Note usage information will already have
// been displayed.
if(!options)
{
 System.exit(1)
}

// If the user has asked for help print the usage information and quit
if(options.h)
{
 cmdLine.usage()
 System.exit(0)
}

keapNames = options.k

grid = false
if(options.g)
{
 grid = true

 // Verify that gridsize is valid (a positive double)
 if(!(options.g instanceof Boolean))
 {
   try
   {
     gridsize = Double.valueOf(options.g)
     if (gridsize <= 0) throw new NumberFormatException()
   }
   catch(NumberFormatException ex)
   {
     System.err.println "error: gridsize must be a positive number"
     cmdLine.usage()
     System.exit(2)
   }
 }
}

// Verify that the scale is valid (a positive double)
try
{
 if(options.s)
 {
   scale = Double.valueOf(options.s)
   if (scale <= 0)
   {
     throw new NumberFormatException()
   }
 }
}
catch(NumberFormatException ex)
{
 System.err.println "error: scale must be a positive number"
 cmdLine.usage()
 System.exit(3)
}

// Verify that the accepting distance is valid (a positive double)
try
{
 if(options.d)
 {
   acceptingDistance = Double.valueOf(options.d)
   if (acceptingDistance <= 0)
   {
     throw new NumberFormatException()
   }
 }
}
catch(NumberFormatException ex)
{
 System.err.println "error: accepting distance must be a positive number"
 cmdLine.usage()
 System.exit(4)
}

if(options.i)
{
 infilename = options.i
}

if(options.o)
{
 try
 {
   outputStream = new FileWriter(options.o)
 }
 catch(Exception e)
 {
   System.err.println "error: unable to write to output file ${options.o}"
   System.exit(5)
 }
}
else
{
 outputStream = System.out
}

// Try to get the text from the file
if(infilename)
{
 try
 {
   body = new File(infilename).text
 }
 catch(IOException ex)
 {
   System.err.println "error: unable to read ${infilename}"
   System.exit(4)
 }
}
else
{
 body = System.in.text
}

// Parse the XML
try
{
 structure = new XmlParser().parseText(body)
}
catch(Exception ex)
{
 System.err.println "error: parsing XML"
 System.exit(5)
}

// Check the type to be sure it is a machine that can be converted
type = structure.type.text()
if(type != "fa" && type != "pda" && type != "turing")
{
 System.err.println "error: unsupported machine type --- only FAs, PDAs and Turing machines can be converted"
 System.exit(6);
}

// If the automaton is a Turing machine, get the number of tapes
try
{
 tapes = Integer.valueOf(structure.tapes.text())
}
catch (Exception ex)
{
 tapes = 1
}

// Find the automaton in the XML tree
automaton = structure.automaton
if(!automaton)
{
 automaton = structure
}

// Print the LaTeX header
outputStream.println """\\documentclass{article}
\\usepackage{tikz}
\\usetikzlibrary{automata,positioning,arrows.meta}${type == "turing" ? "\n\\usepackage{amssymb}\n" : ""}
\\usepackage[graphics,tightpage,active,pdftex]{preview}
\\setlength{\\PreviewBorder}{5pt}
\\PreviewEnvironment{tikzpicture}
\\begin{document}
\\begin{tikzpicture}[>={Stealth[width=${arrowWidth}pt,length=${arrowLength}pt]}, accepting/.style={double distance = ${acceptingDistance}pt, outer sep = ${acceptingDistance/2.0}pt + \\pgflinewidth}, shorten >=1pt${options.r ? "" : ", auto"}]"""

// For each state in the XML define a TikZ node and record their position
statePosition = [:]
automaton.state.each
{
 // Get the x and y value of the position, correcting for the change in
 // coordinate systems
 x = Double.valueOf(it.x.text()) * scale
 y = -Double.valueOf(it.y.text()) * scale
 if(grid)
 {
   x = Math.round(x / gridsize) * gridsize
   y = Math.round(y / gridsize) * gridsize
 }

 // record position
 statePosition[it.@id] = [x,y]

 // get state name
 def stateName
 if(keapNames)
 {
   stateName = it.@name
 }
 else
 {
   stateName = "\$q_{${fixText(it.@id)}}\$"
 }

 // Output the TikZ version of this state
 outputStream.println "  \\draw (${x}pt, ${y}pt)" +
   "node[state${it.initial ? ", initial, initial text =" : ""}" +
   "${it.get("final") ? ", accepting" : ""}]" +
   "(${fixText(it.@id)}){$stateName};"
}

// Define a function that makes a key based on the end points of a transition
// without taking into account the order of the keys. In other words, edge
// (a,b) results in the same key as the edge (b,a).
String makeKey(String a, String b)
{
 return a < b ? "${a},${b}" : "${b},${a}"
}

// Scan transitions and create their labels. Also, check to see if there are
// edges going in both directions.
doubleEdges = [:]
edgeLabels = [:]
automaton.transition.each
{
 orderedKey = [it.from.text(), it.to.text()]

 // Have we seen the reverse of this edge before?
 if(edgeLabels.get([it.to.text(), it.from.text()]) &&
   it.to.text() != it.from.text())
 {
   doubleEdges.put(makeKey(it.from.text(), it.to.text()), true)
 }

 // Get the read symbol. If it is blank use lambda for the symbol.
 if(!(readSymbol = fixText(it.read.text())))
 {
   readSymbol = blank
 }

 // Get the pop and push symbols if the machine is a PDA
 if(type == "pda")
 {
   if(!(popSymbol = fixText(it.pop.text())))
   {
     popSymbol = blank
   }
   if(!(pushSymbol = fixText(it.push.text())))
   {
     pushSymbol = blank
   }
 }

 // If the machine is a Turing machine, get all of the read, write, and move
 // information
 if(type == "turing")
 {
   readSymbols = []
   it.read.each{r->
     readSymbols << (r.text() ? fixText(r.text()) : turingBlank)
   }

   writeSymbols = []
   it.write.each{w->
     writeSymbols << (w.text() ? fixText(w.text()) : turingBlank)
   }

   moves = []
   it.move.each{m->
     moves << fixText(m.text())
   }
 }

 // Make the label for this transition
 if(type == "fa")
 {
   label = readSymbol;
 }
 else if(type == "pda")
 {
   label = "${readSymbol}, ${popSymbol}; ${pushSymbol}"
 }
 else if(type == "turing")
 {
   // Merge the read, write and move lists into one label
   label = []
   readSymbols.eachWithIndex{read, i->
     label << "${read} : ${writeSymbols[i]}, ${moves[i]}"
   }
   label = label.join('$|$')
 }

 // Add this edge's label to the edge label list
 if((oldLabel = edgeLabels.get(orderedKey)))
 {
   oldLabel << label
 }
 else
 {
   edgeLabels.put(orderedKey, [label])
 }
}

// For each transition draw an edge
edgeLabels.each
{
 // Turn the label into a string
 if(type == "fa")
 {
   label = it.value.join(", ")
 }
 else
 {
   label = it.value.join("\\\\ ")
 }

 // Construct the options for the node that will hold the label
 nodeOptions = []
 if(type != "fa" && it.value.size() > 1) // Are line breaks needed?
 {
   nodeOptions << "align=center"
 }
 if(options.r && it.key[0] != it.key[1]) // Does the text need to be rotated?
 {
   nodeOptions << "sloped"

   // In the case of rotations auto positioning is not used. Thus, an anchor
   // point for the node must be given.
   if(statePosition[it.key[0]][0] < statePosition[it.key[1]][0])
   {
     nodeOptions << "anchor=south"
   }
   else
   {
     nodeOptions << "anchor=north"
   }
 }

 // Turn the node options into a string. If there are no options then use the
 // empty string. Otherwise join the option with commas and surround them with
 // square brackets.
 if(nodeOptions.size() > 0)
 {
   nodeOptions = "[${nodeOptions.join(",")}]"
 }
 else
 {
   nodeOptions = ""
 }

 outputStream.println "  \\path[->] (${it.key[0]}) edge" +
 (it.key[0] == it.key[1] ? "[loop above]" : "") +   // Do we have a self loop?
 (doubleEdges.get(makeKey(it.key[0], it.key[1])) ? "[bend left]" : "") + // Do we have more than one edge? If so add a bend.
   " node${nodeOptions}" +
   "{${label}}(${it.key[1]});"
}

// Print the LaTeX footer
outputStream.println """\\end{tikzpicture}
\\end{document}"""
outputStream.close()