#!/usr/bin/env python
#
# Up-convert WML and maps between versions.
#
# By Eric S. Raymond April 2007.
#
# All conversion logic for lifting WML and maps from older versions of the
# markup to newer ones should live here.  This includes resource path changes
# and renames, also map format conversions.
#
# The recommended procedure is this:
# 1. Run it with --dryrun first to see what it will do.
# 2. If the messages look good, run without --dryrun; the old content
#    will be left in backup files with a -bak extension.
# 3. Eyeball the changes with the --diff option.
# 4. Test the conversion.
# 5. Use either --clean to remove the -bak files or --revert to
#    undo the conversion.
#
# This script presently makes no effort to fix terrain codes outside of maps,
# e.g. in terrain filters. It will barf on maps with custom terrains.

import sys, os, re, getopt

filemoves = {
    # Older includes all previous to 1.3.1.
    "older" : (
        # These are picked to cover as many as possible of the broken
        # references in UMC on the campaign server.  Some things we
        # don't try to fix include:
        # - attack/staff.png may map to one of several staves
        ("SOUND_LIST:DAGGER_SWISH", "SOUND_LIST:SWORD_SWISH"),
        ("AMLA_TOUGH_2",            "AMLA_TOUGH 2"),
        ("AMLA_TOUGH_3",            "AMLA_TOUGH 3"),
        ("bow-hit.ogg",             "bow.ogg"),
    	("creepy.ogg",              "underground.ogg"),
        ("arrow-miss.wav",          "bow-miss.ogg"),
        ("arrow-hit.wav",           "bow.ogg"),
        ("bow-hit.wav",             "bow.ogg"),
        ("attacks/darkstaff.png",   "attacks/staff-necromantic.png"),
        ("attacks/sword.png",       "attacks/human-sword.png"),
        ("eagle.wav",               "gryphon-shriek-1.ogg"),
        ("fist.wav",                "fist.ogg"),
        ("knife.wav",               "dagger-swish.wav"),	# Is this right?
        ("miss.wav",                "miss-1.ogg"),
        ("wolf-attack.wav",         "wolf-bite.ogg"),
        ("wolf-cry.wav",            "wolf-die.ogg"),
        ("items/castle-ruins",      "scenery/castle-ruins.png"),
        ("items/leanto",            "scenery/leanto.png"),
        ("items/fire1.png",         "scenery/fire1.png"),
        ("items/fire2.png",         "scenery/fire2.png"),
        ("items/fire3.png",         "scenery/fire3.png"),
        ("items/fire4.png",         "scenery/fire4.png"),
        ("items/monolith1.png",     "scenery/monolith1.png"),
        ("items/monolith2.png",     "scenery/monolith2.png"),
        ("items/monolith3.png",     "scenery/monolith3.png"),
        ("items/monolith4.png",     "scenery/monolith4.png"),
        ("items/ring1.png",         "items/ring-silver.png"),	# Is this right?
        ("items/ring2.png",         "items/ring-gold.png"),	# Is this right?
        ("items/rock1.png",         "scenery/rock1.png"),
        ("items/rock2.png",         "scenery/rock2.png"),
        ("items/rock3.png",         "scenery/rock3.png"),
        ("items/rock4.png",         "scenery/rock4.png"),
        ("items/signpost.png",      "scenery/signpost.png"),
        ("terrain/signpost.png",    "scenery/signpost.png"),
        ("terrain/rocks.png",       "scenery/rock2.png"),
        ("items/well.png",          "scenery/well.png"),
        ("lightning.wav",           "lightning.ogg"),
        ("misc/dwarven-doors.png",  "scenery/dwarven-doors-closed.png"),
        ("misc/chest.png",          "items/chest.png"),
        ("misc/temple.png",         "scenery/temple1.png"),
        ("sword-swish.wav",         "sword-1.ogg"),
        ("sword.wav",               "sword-1.ogg"),
        ("terrain/flag-1.png",      "flags/flag-1.png"),
        ("terrain/flag-2.png",      "flags/flag-2.png"),
        ("terrain/flag-3.png",      "flags/flag-3.png"),
        ("terrain/flag-4.png",      "flags/flag-4.png"),
        ("terrain/village-cave-tile.png","terrain/village/cave-tile.png"),
        ("terrain/village-dwarven-tile.png","terrain/village/dwarven-tile.png"),
    ),
    "1.3.1" : (
        # Peasant images moved to a new directory
        ("human-loyalists/peasant.png", "human-peasants/peasant.png"),
        ("human-loyalists/peasant-attack.png", "human-peasants/peasant-attack.png"),
        ("human-loyalists/peasant-attack2.png", "human-peasants/peasant-attack2.png"),
        ("human-loyalists/peasant-ranged.png", "human-peasants/peasant-ranged.png"),
        ("human-loyalists/peasant-idle-1.png", "human-peasants/peasant-idle-1.png"),
        ("human-loyalists/peasant-idle-2.png", "human-peasants/peasant-idle-2.png"),
        ("human-loyalists/peasant-idle-3.png", "human-peasants/peasant-idle-3.png"),
        ("human-loyalists/peasant-idle-4.png", "human-peasants/peasant-idle-4.png"),
        ("human-loyalists/peasant-idle-5.png", "human-peasants/peasant-idle-5.png"),
        ("human-loyalists/peasant-idle-6.png", "human-peasants/peasant-idle-6.png"),
        ("human-loyalists/peasant-idle-7.png", "human-peasants/peasant-idle-7.png"),
        # All Great Mage attacks were renamed
        ("great-mage-attack-magic1.png", "great-mage-attack-magic-1.png"),
        ("great-mage-attack-magic2.png", "great-mage-attack-magic-2.png"),
        ("great-mage+female-attack-magic1.png", "great-mage+female-attack-magic-1.png"),
        ("great-mage+female-attack-magic2.png", "great-mage+female-attack-magic-2.png"),
        ("great-mage-attack-staff1.png", "great-mage-attack-staff-1.png"),
        ("great-mage-attack-staff2.png", "great-mage-attack-staff-2.png"),
        ("great-mage+female-attack-staff1.png", "great-mage+female-attack-staff-1.png"),
        ("great-mage+female-attack-staff2.png", "great-mage+female-attack-staff-2.png"),
        # All Arch Mage attacks were renamed
        ("arch-mage-attack-magic1.png", "arch-mage-attack-magic-1.png"),
        ("arch-mage-attack-magic2.png", "arch-mage-attack-magic-2.png"),
        ("arch-mage+female-attack-magic1.png", "arch-mage+female-attack-magic-1.png"),
        ("arch-mage+female-attack-magic2.png", "arch-mage+female-attack-magic-2.png"),
        ("arch-mage-attack-staff1.png", "arch-mage-attack-staff-1.png"),
        ("arch-mage-attack-staff2.png", "arch-mage-attack-staff-2.png"),
        ("arch-mage+female-attack-staff1.png", "arch-mage+female-attack-staff-1.png"),
        ("arch-mage+female-attack-staff2.png", "arch-mage+female-attack-staff-2.png"),
        # All Red Mage attacks were renamed
        ("red-mage-attack-magic1.png", "red-mage-attack-magic-1.png"),
        ("red-mage-attack-magic2.png", "red-mage-attack-magic-2.png"),
        ("red-mage+female-attack-magic1.png", "red-mage+female-attack-magic-1.png"),
        ("red-mage+female-attack-magic2.png", "red-mage+female-attack-magic-2.png"),
        ("red-mage-attack-staff1.png", "red-mage-attack-staff-1.png"),
        ("red-mage-attack-staff2.png", "red-mage-attack-staff-2.png"),
        ("red-mage+female-attack-staff1.png", "red-mage+female-attack-staff-1.png"),
        ("red-mage+female-attack-staff2.png", "red-mage+female-attack-staff-2.png"),
	# Timothy Pinkham supplied titles for two of his music files.
        # Zhaytee supplied a title for wesnoth-1.ogg
	# gameplay03.ogg, and and wesnoth-[25].ogg already had titles.
	("gameplay01.ogg", "knolls.ogg"),
	("gameplay02.ogg", "wanderer.ogg"),
	("gameplay03.ogg", "battle.ogg"),
        ("wesnoth-1.ogg", "revelation.ogg"),
        ("wesnoth-2.ogg", "loyalists.ogg"),
        ("wesnoth-5.ogg", "northerners.ogg"),
    ),
    # An empty sentinel value at end is required.
    # Always have the current version here.
    "1.3.2" : (),
}

# 1.2.x to 1.3.2 terrain conversion
conversion1 = {
    " " : "_s",
    "&" : "Mm^Xm",
    "'" : "Uu^Ii",
    "/" : "Ww^Bw/",
    "1" : "1 _K",
    "2" : "2 _K",
    "3" : "3 _K",
    "4" : "4 _K",
    "5" : "5 _K",
    "6" : "6 _K",
    "7" : "7 _K",
    "8" : "8 _K",
    "9" : "9 _K",
    "?" : "Gg^Fet",
    "A" : "Ha^Vhha",
    "B" : "Dd^Vda",
    "C" : "Ch",
    "D" : "Uu^Vu",
    "E" : "Rd",
    "F" : "Aa^Fpa",
    "G" : "Gs",
    "H" : "Ha",
    "I" : "Dd",
    "J" : "Hd",
    "K" : "_K",
    "L" : "Gs^Vht",
    "M" : "Md",
    "N" : "Chr",
    "P" : "Dd^Do",
    "Q" : "Chw",
    "R" : "Rr",
    "S" : "Aa",
    "T" : "Gs^Ft",
    "U" : "Dd^Vdt",
    "V" : "Aa^Vha",
    "W" : "Xu",
    "X" : "Qxu",
    "Y" : "Ss^Vhs",
    "Z" : "Ww^Vm",
    "[" : "Uh",
    "\\": "Ww^Bw\\",
    "]" : "Uu^Uf",
    "a" : "Hh^Vhh",
    "b" : "Mm^Vhh",
    "c" : "Ww",
    "d" : "Ds",
    "e" : "Aa^Vea",
    "f" : "Gs^Fp",
    "g" : "Gg",
    "h" : "Hh",
    "i" : "Ai",
    "k" : "Wwf",
    "l" : "Ql",
    "m" : "Mm",
    "n" : "Ce",
    "o" : "Cud",
    "p" : "Uu^Vud",
    "q" : "Chs",
    "r" : "Re",
    "s" : "Wo",
    "t" : "Gg^Ve",
    "u" : "Uu",
    "v" : "Gg^Vh",
    "w" : "Ss",
    "|" : "Ww^Bw|",
    "~" : "_f",
}
max_len = max(*map(len, conversion1.values()))

def get_adjacent(x, y, map):
    "Returns string of original location+adjacent locations on hex 1-char map"
    odd = (x) % 2
    adj = map[y][x];
    if x > 0:
	adj += map[y][x-1]
    if x < len(map[y])-1:
	adj += map[y][x+1]
    if y > 0:
	adj += map[y-1][x]
    if y < len(map)-1:
	adj += map[y+1][x]
    if x > 0 and y > 0 and not odd:
	adj += map[y-1][x-1]
    if x < len(map[y])-1 and y > 0 and not odd:
	adj += map[y-1][x+1];
    if x > 0 and y < len(map)-1 and odd:
	adj += map[y+1][x-1]
    if x < len(map[y])-1 and y < len(map)-1 and odd:
	adj += map[y+1][x+1]
    adj = adj.replace("\n", "").replace("\r", "")
    return adj

width = max_len+2

def maptransform1(input, baseline, inmap, y):
    "Transform a map line from 1.2.x to 1.3.x format."
    format = "%%%d.%ds" % (width, max_len)
    x = 0
    if "," in inmap[y]:
        raise maptransform_error(input, baseline, x, y,
                                 "map file appears to be converted already")
    line = ''
    for char in inmap[y]:
        ohex = ''
        if char in ('\n', '\r'):
            ohex += char
        elif char in conversion1:
            ohex = format % conversion1[char] + ','
        else:
            raise maptransform_error(input, baseline+y+1, x, y,
                                     "unrecognized character %s (%d)" % (`char`, ord(char)))
            # ohex = format % char
            sys.exit(1)
        if "_K" in ohex:
            # Convert keeps according to adjacent hexes
            adj = get_adjacent(x, y, inmap)
            # print "adjacent: %s" % adj
            hexcount = {}
            for i in range(1, len(adj)):
                # Intentionally skipping 0 as it is original hex
                a = adj[i];
                if not a in conversion1:
                    raise maptransform_error(input, baseline, x, y,
                                 "error in adjacent hexes")
                    sys.exit(1)
                ca = conversion1[a]
                if ca.startswith("C"): #this is a castle hex	
                     hexcount[ca] = hexcount.get(ca, 0) + 1
            maxc = 0;
            maxk = "Ch";
            # Next line is a hack to make this code pass
            # regression testing against the Perl
            # original. Without the sort, when there are
            # two terrain types that occur in equal
            # numbers greater than any others, which one
            # gets picked will be randomly dependent on
            # Python's dictionary hash function.
            sorted = hexcount.keys()
            sorted.sort()
            for k in sorted:
                if hexcount[k] > maxc:
                    maxc = hexcount[k]
                    maxk = k
            #print "Dominated by %s" % maxk
            maxk = re.sub("^C", "K", maxk)
            ohex = ohex.replace("_K", maxk)
        line += ohex
        x += 1
    return line.replace(",\n", "\n")

# 1.3.1 -> 1.3.2 terrain conversions
conversion2 = {
    re.compile(r"(?<!\^)Bww([|/\\])\b") : "Ww^Bw\\1",
    re.compile(r"(?<!\^)Bwo([|/\\])\b") : "Wo^Bw\\1",
    re.compile(r"(?<!\^)Bss([|/\\])\b") : "Ss^Bw\\1",
    re.compile(r"(?<!\^)Dc\b") : "Dd^Dc",
    re.compile(r"(?<!\^)Dr\b") : "Dd^Dr",
    re.compile(r"(?<!\^)Do\b") : "Dd^Do",
    re.compile(r"(?<!\^)Fa\b") : "Aa^Fpa",
    re.compile(r"(?<!\^)Fet\b") : "Gg^Fet",
    re.compile(r"(?<!\^)Ff\b") : "Gs^Fp",
    re.compile(r"(?<!\^)Ft\b") : "Gs^Ft",
    re.compile(r"(?<!\^)Rfvs\b") : "Re^Gvs",
    re.compile(r"(?<!\^)Uf\b") : "Uu^Uf",
    re.compile(r"(?<!\^)Uui\b") : "Uu^Ii",
    re.compile(r"(?<!\^)Uhi\b") : "Uh^Ii",
    re.compile(r"(?<!\^)Vda\b") : "Dd^Vda",
    re.compile(r"(?<!\^)Vdt\b") : "Dd^Vdt",
    re.compile(r"(?<!\^)Vea\b") : "Aa^Vea",
    re.compile(r"(?<!\^)Veg\b") : "Gg^Ve",
    re.compile(r"(?<!\^)Vha\b") : "Aa^Vha",
    re.compile(r"(?<!\^)Vhg\b") : "Gg^Vh",
    re.compile(r"(?<!\^)Vhh\b") : "Hh^Vhh",
    re.compile(r"(?<!\^)Vhha\b") : "Ha^Vhha",
    re.compile(r"(?<!\^)Vhm\b") : "Mm^Vhh",
    re.compile(r"(?<!\^)Vht\b") : "Gs^Vht",
    re.compile(r"(?<!\^)Vu\b") : "Uu^Vu",
    re.compile(r"(?<!\^)Vud\b") : "Uu^Vud",
    re.compile(r"(?<!\^)Vwm\b") : "Ww^Vm",
    re.compile(r"(?<!\^)Vs\b") : "Ss^Vhs",
    re.compile(r"(?<!\^)Vsm\b") : "Ss^Vm",
    re.compile(r"(?<!\^)Xm\b") : "Mm^Xm",
    }

def maptransform2(input, baseline, inmap, y):
    "Convert a map line from 1.3.1 multiletter format to 1.3.2 format."
    mapline = inmap[y]
    for (old, new) in conversion2.items():
        mapline = old.sub(new, mapline)
    return mapline

# Generic machinery starts here

class maptransform_error:
    "Error object to be thrown by maptransform."
    def __init__(self, infile, inline, x, y, type):
        self.infile = infile
        self.inline = inline
        self.x = x
        self.y = y
        self.type = type
    def __repr__(self):
        return '"%s", line %d: %s at (%d, %d)' % \
                   (self.infile, self.inline, self.type, self.x, self.y)

def translator(input, mapxform, textxform):
    "Apply mapxform to map lines and textxform to non-map lines."
    modified = False
    # This hairy regexp excludes map_data lines that contain {} file
    # references, also lines that are empty or hold just one keep
    # character (somewhat pathological, but not handling these will
    # make the regression tests break).
    mapdata = re.compile(r'map_data="[A-Za-z0-9\/|\\&_~?\[\]\']{2,}') 
    mfile = []
    map_only = not input.endswith(".cfg")
    for line in open(input):
        mfile.append(line);
        if mapdata.search(line):
            map_only = False
    cont = False
    outmap = []
    newdata = []
    lineno = baseline = 0
    while mfile:
        line = mfile.pop(0)
        lineno += 1
        if map_only or mapdata.search(line):
            baseline = 0
            cont = True
            # Assumes map is more than 1 line long.
            if not map_only:
                line = line.split('"')[1]
            if line:
                outmap.append(line)
            while cont and mfile:
                line = mfile.pop(0)
                lineno += 1
                if line and line[0] == '#':
                    newdata.append(line)
                    continue
                if '"' in line:
                    cont = False
                    line = line.split('"')[0]
                if line and not line.endswith("\n"):
                    line += "\n"
                if line:
                    outmap.append(line)
            if not map_only: 
                line="map_data=\"\n";
                newdata.append(line)
            for y in range(len(outmap)):
                newline = mapxform(input, baseline, outmap, y)
                newdata.append(newline)
                if newline != outmap[y]:
                    modified = True
            # All lines of the map are processed, add the appropriate trailer
            if map_only:
                line="\n"
            else:
                line="\"\n"
            newdata.append(line)
        else:
            # Handle text (non-map) lines
            newline = textxform(input, lineno, line)
            newdata.append(newline)
            if newline != line:
                modified = True
    # Return None if the transformation functions made no changes.
    if modified:
        return "".join(newdata)
    else:
        return None

ignore = (".tgz", ".png", ".jpg")

def allcfgfiles(dir):
    "Get the names of all .cfg files under dir, ignoring .svn directories."
    datafiles = []
    os.path.walk(dir,
                 lambda arg, dir, names: datafiles.extend(map(lambda x: os.path.normpath(os.path.join(dir, x)), names)),
                 None)
    datafiles = filter(lambda x: ".svn" not in x, datafiles)
    datafiles = filter(lambda x: x.endswith(".cfg") or ('maps' in x and os.path.isfile(x) and x[-4:] not in ignore), datafiles)
    datafiles = filter(lambda x: not x.endswith("-bak"), datafiles)
    return datafiles

def help():
        sys.stderr.write("""\
Usage: upconvert [options]
    Convert Battle of Wesnoth WML from older versions to newer ones.
    Options may be any of these:
    -h, --help                 Emit this help message and quit
    -d, --dryrun               List changes but don't perform them.
    -o, --oldversion           Specify version to begin with.
    -v, --verbose              List files as they are examined.
    -q, --quiet                Suppress non-error messages
    -c, --clean                Clean up -bak files
    -D, --diff                 Display diffs
    -r, --revert               Revert the conversion from the -bak files
""")

if __name__ == '__main__':
    (options, arguments) = getopt.getopt(sys.argv[1:], "cdDho:rv", [
	"help",
        "oldversion=",
	"dryrun",
        "verbose",
        "quiet",
        "clean",
        "revert",
        "diffs",
        ])
    oldversion = 'older'
    dryrun = False
    verbose = False
    quiet = False
    clean = False
    diffs = False
    revert = False
    for (switch, val) in options:
        if switch in ('-h', '--help'):
            help()
            sys.exit(0)
        elif switch in ('-o', '--oldversion'):
            oldversion = val
        elif switch in ('-v', '--verbose'):
            verbose = True
        elif switch in ('-q', '--quiet'):
            quiet = True
        elif switch in ('-d', '--dryrun'):
            dryrun = True
        elif switch in ('-c', '--clean'):
            clean = True
        elif switch in ('-d', '--diffs'):
            diffs = True
        elif switch in ('-r', '--revert'):
            revert = True

    if clean and revert:
        sys.stderr.write("upconvert: can't do clean and revert together.\n")
        sys.exit(1)

    # Compute the series of version upgrades to perform, and describe it.
    versions = filemoves.keys()
    versions.sort()
    versions = [versions[-1]] + versions[:-1]	# Move 'older' to front
    if oldversion in versions:
        versions = versions[versions.index(oldversion):]
    else:
        print >>sys.stderr, "upconvert: unrecognized version."
        sys.exit(1)
    explain = "Performing upgrades for:"
    for i in range(len(versions)-1):
        explain += " %s -> %s," % (versions[i],  versions[i+1])
    sys.stdout.write(explain[:-1] + ".\n")
    fileconversions = map(lambda x: filemoves[x], versions[:-1])

    def texttransform(input, lineno, line):
        "Resource-name transformation on text lines."
        transformed = line
        for step in fileconversions:
            for (old, new) in step:
                transformed = transformed.replace(old, new)
        if not quiet and transformed != line:
            print "%s, line %d: %s -> %s" % \
                  (input, lineno+1, line.strip(), transformed.strip())
        return transformed

    if "1.3.1" in versions and "older" not in versions:
        maptransform = maptransform2
    else:
        maptransform = maptransform1

    # Perform resource file substitutions
    ofp = None
    for fn in allcfgfiles("."):
        if verbose:
            print fn
        backup = fn + "-bak"
        if clean or revert:
            # Do housekeeping
            if os.path.exists(backup):
                if clean:
                    print "Removing %s" % backup
                    if not dryrun:
                        os.remove(backup)
                elif revert:
                    print "Reverting %s" % backup
                    if not dryrun:
                        os.rename(backup, fn)
        elif diffs:
            # Display diffs
            if os.path.exists(backup):
                print fn
                os.system("diff -u %s %s" % (backup, fn))
        else:
            # Do file conversions
            try:
                changed = translator(fn, maptransform, texttransform)
                if changed:
                    if not quiet:
                        print "%s modified." % fn
                    if not dryrun:
                        os.rename(fn, backup)
                        ofp = open(fn, "w")
                        ofp.write(changed)
                        ofp.close()
            except maptransform_error, e:
                sys.stderr.write("upconvert: " + `e` + "\n")

# upconvert ends here
