# This file handles the detailed queries and modifications of bundle elements. It does the bulk of the work of procedure editing and is simply wrapped by the Main.py function which calls it for each bundle and other queries

# -----------------
# IMPORTS... Possible to cover these with a parent script?
from lxml import etree as ET # LXML version of import. LXML should be available on NWS LX workstations
import re # used for regular expression matches
import readline # used for preset, editable text on input prompts
from copy import deepcopy # used to COPY identical elements for multi-use (e.g. gridGeometry), as otherwise can only be used once
from collections import OrderedDict # needed for rResources to preserve order of added keys
# -----------------
# UTILITY FUNCTIONS AND DECLARATIONS

# Supposedly useful for later export?
ns = {'xsi':'http://www.w3.org/2001/XMLSchema-instance'}
for k in ns:
    print(k)
    print(ns[k])
    ET.register_namespace(k,ns[k])
# Make a string which can always find us our coveted xsi:type
xsitype='{{{}}}type'.format(ns['xsi'])

# COPIED FROM PromptTest FILE
# Make function which creates xml tree object out of file
def xmlObjFromFile(filePath):
    # Open the file
    f = open(filePath, 'rt')

    # Now parse it into an ElementTree
    parser = ET.XMLParser(ns_clean=True) # xml's XMLParser doesn't take this argument. THIS COULD BE A PROBLEM FOR NAMESPACE CLEANUP
    tree = ET.parse(f,parser) # SEEMS TO ONLY WORK FOR LXML
    ####tree = ET.parse(f) # Simpler parse command to work for xml lib
    f.close()
    return tree



# CLEVER FUNCTION FOR PRINTING FEEDBACK only if set a "verbose" flag. SOURCE: stackoverflow.com/questions/5980042
try:
    verbose # Check if variable was set
except:
    verbose = False # Set value if global variable wasn't found. Set to true to show more debugging messages
# Now define function depending on status of variable
if verbose:
    def verboseprint(*args):
        # Print each argument seperately so caller doesn't need to stuff everything to be printed into a single string
        for arg in args:
            print(arg, end=' ')
        print()
else:
    verboseprint = lambda *a: None # do-nothing function


# --------------------------
# Definitions for raw input which allows preset text
# REQUIRES readline module

def default_hook():
    """Insert some default text into the raw_inpug."""
    readline.insert_text(default_hook.default_text)
    readline.redisplay()

readline.set_pre_input_hook(default_hook)

# SUPPORTING function to remove tabs from string and replace with spaces
#   --> Hopefully fixes display bug when you delete text on prompt lines
def replaceTabs(inputStr):
    numSpacesInTab=8 # Seems to be standard
    try:
        cleanStr=inputStr.replace("\t",' '*numSpacesInTab)
    except:
        cleanStr = inputStr
    return cleanStr

def raw_input_default(prompt, default=None, cleanTabs=True):
    # main functino for prompt which displays pre-set text
    DEFAULT_TEXT = ''
    default_hook.default_text=DEFAULT_TEXT if default is None else default
    # Before presenting prompt, replace tabs if asked
    if cleanTabs:
        prompt = replaceTabs(prompt)
    response=input(prompt)
    default_hook.default_text=DEFAULT_TEXT # SET BACK TO DEFAULT
    return response

# -----------------
# Main commands 


# THIS WOULD PROBABLY BE BETTER IN A SEPERATE SCRIPT, but just for now:
import csv # Dependancy for radar lookup
def prepRadarLookupDict(filePath):
    d={} # define dictionary for saving results
    with open(filePath,'r') as f:
        reader=csv.reader(f)
        headers=next(reader)[1:]
        for row in reader:
            # for all headers, assign cleaned ("strip") entry as dictionary value
            d[row[0]]= {key: value.strip() for key, value in zip(headers, row[1:])}
    return d

radarFile='RadarProducts.csv' # ASSUMES it's in current directory
radarProdDict=prepRadarLookupDict(radarFile)
verboseprint(radarProdDict)

def radarProductLookup(prodCode):
    # Assumes that radarProdDict already exists... else modify function to include as argument
    try:
        translation=radarProdDict[prodCode]['Mnemonic']
    except: # Not ALL codes might be found
        translation=prodCode # just return product code
    # Above is where we specify what we pass as the human-readable "translation" of the product code
    return translation



# This is a wrapper for the radarProductLookup which handles more of label management for radar displays
def translateRadarLabel(pcodeSet):
    # Basically, some radar products accept a set of product codes. They may be redundant, and so we need to parse the code and come up with one simple (if possible) label to capture what they all mean. For example, you may be requesting SW or HSW, whichever is available... we'll operate under the assumption that the "superior" products in this case has the longer mnemonic, so we'll pick HSW by this metric.
    # Iterate through the pcodes
    pSet=pcodeSet.split(',')
    translation=[]
    for p in pSet:
        if len(p)==0:
            #print 'EMPTY CODE'
            continue
        else:
            t=radarProductLookup(p)
            if t not in translation:
                translation.append(t)
    # Simplify translation?
    # CURRENT STRATEGY: Search for longest items, apply preference to text
    final=''
    for s in translation:
        try:
            int(s)
            # We want to skip this if it worked, since it means it was an integer
        except:
            # If we found text, check if it is longer than the current final text selection
            if len(s)>len(final):
                final=s
    # If we found an option for final, use it, otherwise if got NOTHING out of looking for strings, make a concatenation of the pcodes for our final translation
    if len(final)>0:
        translation=final
    else:
        translation = ','.join(pSet)
    verboseprint(translation)
    return translation



# Make a function which collects bundle names and element handles from a procedure file
def makeBundleList(rootXMLObj):
    # Argument needs to be procedure file or something with one or more bundle names in it
    bundles={}
    # Find bundles
    bundleMatches=rootXMLObj.findall('.//bundle[@name]')
    # Add bundle matches to dictionary
    for b in bundleMatches:
        bundles[b.get('name')]=b

    return bundles



# ======================================================================
# Let's make a function which can do filtering and reordering on the bundle list. This is useful for making the order and number of bundle edits more efficient... for example, editing multi-icao bundles first makes sense because we can make the most logical substitution choices between original radars and the desired replacements, in terms of display layout and geography. These substitutions get saved and applied later.  Also (perhaps later, more advanced handling) we can include logic here to eliminate effectively identical bundles which differ only by a single element like icao, and allow the user to
def arrangeBundles(bundleDict,complexFirst=True,groupRedundant=False): # ALSO GIVE SESSION DATA?
    # Make a "multi-dimensional" list for storing attributes of the bundles to sort/manage by
    bMetaDict={b:{'order':[],'xmlPointer':bundleDict[b],'icaoCount':[],'similarityGroup':[]} for b in bundleDict}


    # Count icaos for each radar
    for b in bMetaDict:
        bundleXmlObj=bMetaDict[b]['xmlPointer']
        verboseprint(bundleXmlObj.get('name'))
        icaoSet=set([match.get('constraintValue') for match in bundleXmlObj.findall('.//mapping[@key="icao"]/constraint')])
        verboseprint(icaoSet)
        icaoCount=len(icaoSet)
        verboseprint(icaoCount)
        bMetaDict[b]['icaoCount']=icaoCount

    verboseprint(bMetaDict)

    # If the complexFirst flag is true, use the icaoCount to order the list by those with the highest number first
    if complexFirst:
        # This uses a temporary ('lambda') function to get a tuple, which acts as the multi-part sort key, which has first the icaoCount, and second the upper-case bundel name
        sortedBundles=sorted(iter(bMetaDict.keys()), key=lambda x: (-bMetaDict[x]['icaoCount'], len(x.upper()),x.upper()))
        verboseprint(sortedBundles)
    else:
        # otherwise sort only based on the name. That is, simplify the key/lambda function to remove the icao aspect
        sortedBundles=sorted(iter(bMetaDict.keys()), key=lambda x: (len(x.upper()),x.upper()))
    return sortedBundles


#===========================================

# FY19 UPDATES
# 1) Fixed erroring out with bad bundle selection prompt (now presents selection prompt again)

# Make a function which allows us to choose a bundle name
def chooseBundle(bundleDict):
    print("Please make a choice from below elements by entering the number (or 'ALL' to edit all)...")
    # USE OUR "arrangeBundles" FUNCTION to bring order (and even filtering) to the list of bundles
    ####sortedBundleNames=sorted(bundleDict.iterkeys(),key=lambda v: v.upper()) # need second arg to make upper and lower case equal in sortOAX
    sortedBundleNames=arrangeBundles(bundleDict)
    print(('\n'.join('{:2d}: {}'.format(*k) for k in enumerate(sortedBundleNames))))

    ###choice=raw_input('ENTER NUMBER [ALL]:\n') or 'ALL' # OLD
    choice = raw_input_default('[EDIT/ENTER]:', default='ALL') or 'ALL'
    # HANDLE INPUT (NEEDS WORK). Currently, depends on it being a number. But let's also allow it to be *. Also need bad entry handling
    if choice.isdigit() and int(choice)< (len(sortedBundleNames)):
        bundleName = sortedBundleNames[int(choice)]
        print(('You entered: {} ({})'.format(choice, bundleName)))
        bundleToMod=tuple([bundleDict[bundleName]]) # if I don't do this, i end up iterating over the xml children of the bundle
        verboseprint(bundleToMod)
    elif choice=='*' or choice.upper()=='ALL': # Accept option to iterate through ALL bundles
        print(('You entered: {} (ITERATE THROUGH ALL BUNDLES)'.format(choice)))
        # Below revision returns bundleXMl objects in order of sortedBundleNames
        bundleToMod=[bundleDict[x] for x in sortedBundleNames]
    else:
        print('INVALID CHOICE: {}'.format(choice))
        # In event of bad argument, employ this same fucntion again by calling on it. This is supported in python as a "recursive" function
        bundleToMod=chooseBundle(bundleDict) # Makes call to the same function to repeat process

    return bundleToMod





# This function makes a list out of the displays in a bundle
def getBundleDisplays(bundleXmlObj):
    displays=bundleXmlObj.findall('.//displays')
    return displays


# REALLY SHOULD WRAP THIS INTO A DISPLAY DICTIONARY OBJECT, but just throwing this here for now, which gets the scale of the display
def getDisplayScale(dispXmlObj):
    return dispXmlObj.get('scale')

# This function determines resource types
def resourceType(rsrcXmlObj):
    rdMatches = rsrcXmlObj.findall('./resourceData') # only searches immediate children
    #print 'NUM RESOURCEDATA: {}'.format(len(rdMatches))
    #print(rdMatches[0].get(xsitype))
    return rdMatches[0].get(xsitype)


def getResourceInfo(rsrcXmlObj):
    rtype = resourceType(rsrcXmlObj)
    verboseprint(rtype)
    # Handle each type here
    if rtype=='dbMapResourceData' or rtype=='mapResourceGroupData' or rtype=='dbPointMapResourceData' or rtype=='spiResourceData' or rtype=='topoResourceData':
        mapname=rsrcXmlObj.find('./resourceData/mapName').text
        label='MAP: ' + mapname
    elif rtype=='lpiResourceData':
        mapname=rsrcXmlObj.find('./resourceData/mapName').text
        label='lpiMAP: ' + mapname
    elif rtype=='gridResourceData' or rtype=='differenceGridResourceData':
        gridName = rsrcXmlObj.find('.//mapping[@key="info.datasetId"]/constraint').get('constraintValue')
        gridParam=rsrcXmlObj.find('.//mapping[@key="info.parameter.abbreviation"]/constraint').get('constraintValue')
        # Going to do a lazy job including the difference products here and just add a simple comment to label
        if 'difference' in rtype:
            gridNote = ' DiffProd'
        else:
            gridNote = ''
        label='{}{} ({})'.format(gridParam,gridNote,gridName)
    elif rtype == 'radarResourceData':
        elev_raw= rsrcXmlObj.find('.//mapping[@key="primaryElevationAngle"]/constraint').get('constraintValue')
        product_raw= rsrcXmlObj.find('.//mapping[@key="productCode"]/constraint').get('constraintValue')
        # With radar, need to do a little interpretation
        if elev_raw=='0.0--360.0':
            elev='AllT' # All Tilts
        elif elev_raw=='0.5--0.5':
            elev='0.5' # 0.5 deg tilt
        elif elev_raw=='0.0--0.0':
            elev='sfc' #  Might not be most accurate to call sfc, but close enough
        else:
            elev=elev_raw
        # Next try to translate the raw product codes into something easy to read and simplified
        product=translateRadarLabel(product_raw)
        icao=rsrcXmlObj.find('.//mapping[@key="icao"]/constraint').get('constraintValue')
        label='{} ({}){}'.format(icao,elev,product)
    elif rtype =='precipRateResourceData':
        # Simple label like "KXXX PrecipRate"
        icao = rsrcXmlObj.find('.//mapping[@key="icao"]/constraint').get('constraintValue')
        label = '{} PrecipRate'.format(icao)
    elif rtype == 'metarPrecipResourceData':
        # VERY simple standard label... POSSIBLE we could get duration but that's not so crucial
        label = 'METAR Precip'
    elif rtype == 'plotResourceData':
        # Generally seems to be point obs... only consistent field is pluginName, but can also try to get reportType
        plugin = rsrcXmlObj.find('.//mapping[@key="pluginName"]/constraint').get('constraintValue')
        try:
            reportType = rsrcXmlObj.find('.//mapping[@key="reportType"]/constraint').get('constraintValue')
        except:
            reportType=None
        label = '{}'.format(plugin) if not reportType else '{} ({})'.format(plugin,reportType)
    elif rtype=='blendedResourceData' or rtype=='satBlendedResourceData' or rtype=='plotBlendedResourceData' or rtype=='trueColorResourceGroupData':
        verboseprint('*ENTERING BLENDED RESOURCE SEARCH*')
        # FOR NOW, just try putting a second level search in here, which sends to this same exact function
        groupItems=[]
        # [NEW MAY 2020] Adding 'trueColorResourceGroupData' support
        # --> This is a blended resource type, BUT it has a different sub-structure of resources
        # --> Introducing a conditional handling of blended types, with special types checked first, and a catch-all for all others (with shared handling)
        if rtype=='trueColorResourceGroupData':
            # trueColorResourceGroupData has a ./resourceDAta/channelResoruce child layout (i.e. resource renamed channelResource)
            childRsrcPath='./resourceData/channelResource'
        else:
            # Most blended resource have a ./resourceData/resource child layout
            childRsrcPath='./resourceData/resource'
        
        #for r in rsrcXmlObj.findall('./resourceData/resource'): # Original, assumed all the same
        for r in rsrcXmlObj.findall(childRsrcPath): # Now using a conditional value of childRsrchPath based on rytpe
            groupItems.append(getResourceInfo(r)[1]) # [1] is for getting the label part since this function returns two things
        label=" + ".join(groupItems)
    elif rtype=='ffgVizGroupResourceData':
        # ffgViz is different than other 'blended' resource data. It has a grid resource for EVERY RFC, and thus would be a needlessly huge label to show. Rather, just grab the name value of rht ffgVizGroupResourceData and be done with it
        ffgName=rsrcXmlObj.find('./resourceData').get('name')
        label=ffgName
    elif rtype=='ffmpResourceData':
        # CAVEAT: NOT ALL FFMP resources have dataKey mapping constraint (e.g. "XMRG" types will fail).
        try:
            source=rsrcXmlObj.find('.//mapping[@key="dataKey"]/constraint').get('constraintValue')
        except:
            # In failure cases, get different info
            source_1=rsrcXmlObj.find('./resourceData').get('dataKey')
            source_2=rsrcXmlObj.find('./resourceData').get('siteKey')
            source='{}-{}'.format(source_2,source_1)
        type=rsrcXmlObj.find('.//mapping[@key="sourceName"]/constraint').get('constraintValue')
        label='FFMP {} {}'.format(source,type)
    elif rtype=='gridLightningResourceData' or rtype=='lightningResourceData':
        if 'grid' in rtype:
            gridNote='Grid '
        else:
            gridNote=''
        source = rsrcXmlObj.find('.//mapping[@key="source"]/constraint').get('constraintValue')
        # For lightning, we need more info from the attributes
        lightningTypeSymbols={'handlingPositiveStrikes':'+','handlingNegativeStrikes':'-','handlingCloudFlashes':'C','handlingPulses':'P'}
        lightCat=[]
        # Loop through attributes and check if handling is true, then append representative symbol if so
        lightningRsrcData=rsrcXmlObj.find('.//resourceData')
        for attr in list(lightningTypeSymbols.keys()):
            if lightningRsrcData.get(attr)=='true':
                lightCat.append(lightningTypeSymbols[attr])
        lightCatText='/'.join(lightCat) # Join the symbols together
        label='{}Lightning {} ({})'.format(gridNote,lightCatText,source)
    elif rtype=='satResourceData':
        band = rsrcXmlObj.find('.//mapping[@key="physicalElement"]/constraint').get('constraintValue')
        # Try to shorten band variable if certain repetetive words present
        if str(band).startswith('Imager '):
            band=band[7:] # Lop off first 7 characters
        sector = rsrcXmlObj.find('.//mapping[@key="sectorID"]/constraint').get('constraintValue')
        label = 'Sat {} ({})'.format(band, sector)
    elif rtype=='wwaResourceData' or rtype=='cwaspsResourceData':
        label=rsrcXmlObj.find('.//resourceData').get('name')


    try:
        verboseprint(label)
        return [rtype,label]
    except:
        print('ERROR GETTING RESOURCE INFO: {}'.format(rtype))
        #return [rtype,'']
        return [rtype,'(unidentified)'] # Maybe helpful to give fill-in-marker?

# This useful function will present a nice summary of our display elements
def inventoryDisplay(dispXmlObj):
    rDict={} # IS THIS THE BEST PLACE TO DECLARE IT?
    for r in dispXmlObj.findall('./descriptor/resource'): # Limit to "top-level" resources
        # Next we handle info-gathering for this type
        rtype, rlabel=getResourceInfo(r)
        # Put all of this in a dictionary
        rDict[r]={'xsitype':rtype,'info':rlabel}
    return rDict




# ----------------------------------------------------------------------------------------------------------------
# CREATE A FUNCTION which saves substitutions to to a sessionData dictionary, for purposes of
def saveSubstitution(rtype, origVal, newVal, sessionData):
    # Break the substitutions up into resource types... for each resource type, log the various substitutions that have been made
    sessionSubs=sessionData['substitutions']
    # Check if the rtype exists
    if rtype not in list(sessionSubs.keys()):
        sessionSubs[rtype]={} # Define empty substitution dict for rtype
    # Add the given substitution to the rtype-specific entry if not already there
    if origVal not in list(sessionSubs[rtype].keys()):
        sessionSubs[rtype][origVal]=[newVal] # Go ahead and include the newVal as the first list item
    if newVal not in sessionSubs[rtype][origVal]:
        sessionSubs[rtype][origVal].append(newVal)
    print(('\t--> SAVING SUBSTITUTION: This Substitution will be saved: [OLD] {} --> [NEW] {}'.format(origVal,newVal)))



# DEFINE A supporting function for "checkSubstitution" which handles any exceptional/initial handling
def specialSubstitutions(sessionData, rtype, displayInfo):
    # Is there an 'INITIAL' substitution specified, i.e. from localization imports?
    try:
        # This command retrieves the 'INITIAL' AND removes it from the dictionary, to prevent it from being used again
        newVal=sessionData['substitutions'][rtype].pop('INITIAL')
        return newVal
    except:
        # This will happen a LOT in normal use, so generally don't print anything
        verboseprint('No INITIAL substitutions available for {}'.format(rtype))

    # IF displayInfo provided... THEN, is the bundle a four-panel display?
    if displayInfo and len(displayInfo) == 4:
        # Get set of displays numbers where resource occurs
        dSet = [displayInfo[d]['displayNum'] for d in displayInfo if displayInfo[d]['rscCount'] > 0]
        # Is this a multi-radar display? ONE WAY TO CHECK: does our radar only appear in ONE of the displays?
        if len(dSet)==1:
            panelNum=dSet[0]
            # Then presume (MAYBE NOT THE BEST ASSUMPTION) that we can use this panel location to make a substitution with the provided radar args
            panelPositionKeys = ['topLeft', 'topRight', 'botLeft', 'botRight']
            try:
                newVal=sessionData['substitutions'][rtype].pop(panelPositionKeys[panelNum-1])
                return newVal
            except:
                # This will happen a LOT in normal use, so generally don't print anything
                verboseprint('No INITIAL substitutions available for {}'.format(rtype))



# CREATE FUNCTION which checks the substitutions to see if something is available
def checkSubstitution(rtype,origVal,sessionData,displayInfo=None):
    # For INITIAL or special substitutions (e.g. by panel layout), make call to helper function
    newVal = specialSubstitutions(sessionData,rtype,displayInfo)
    if newVal:
        return newVal

    # For all other usage, look in rtype- and origVal-specific locations to see if any value is present
    try:
        # HERE is where we can implement the behavior of retrieving guesses.
        # newVal=sessionData['substitutions'][rtype][origVal][0] # Grab first item
        newVal=sessionData['substitutions'][rtype][origVal][-1] # ARGUABLY the LAST sub made is BEST since it reflects most recent input
        # COULD ALSO have implemented more advanced support for other formats here like simple strings instead of lists
        return newVal
    except:
        verboseprint('NO SUBSTITIONS for {} (in type {})'.format(origVal,rtype))
        return False



# ----------------------------------------------------------------------------------------------------------------
# DEFINE a utility function which can determine which displays a resource is in. Can pass this multiple resources too.
# This is useful for adding helpful info to prompts, but also for special substitution logic e.g. based on panel layout
def getDisplayInfoForRsrcs(bundleXmlObj,resourceXmlObj):
    # Start by making a list of display elements in bundle, and assign to bundle
    displays = bundleXmlObj.findall('.//displays')
    # Go through each display and assign to dictionary
    displayDict = OrderedDict() # To maintain order as adding items
    for i, d in enumerate(displays):
        displayDict[d] = {'displayNum':i+1, 'rscCount':0}
    # Now determine which displays have how many of the current resource
    for r in resourceXmlObj:
        # Find the parent display
        displayElm = r.xpath('ancestor::displays')
        # Iterate resource count for the matching item in displayDict
        displayDict[displayElm[0]]['rscCount']+=1
    # Return the displayDict
    return displayDict


# RELATED TO ABOVE, DEFINE function which can help translate WHERE a given resource occurs, based on displayDict
def translateDisplayInfo(displayInfo):
    # Goal is to return a string with meaningful info

    # Just manually define a dictionary with possible descriptors for the likely display counts, and each display number
    panelNamesDict={1:{1:'Main'}, # 1 panel
                    4:{1:'Top-Left', 2:'Top-Right', 3:'Bottom-Left', 4:'Bottom-Right'}, # 4 panels
                    6: {1:'Top-Left', 2:'Top-Right', 3:'Middle-Left', # 6 panels
                        4:'Middle-Right', 5:'Bottom-Left', 6:'Bottom-Right'},
                    9: {1: 'Top-Left', 2: 'Top-Middle', 3: 'Top-Right',  # 9 panels
                        4:'Left Middle Row', 5:'Center', 6:'Right Middle Row',
                        7:'Bottom-Left', 8:'Bottom-Middle', 9:'Bottom-Right'}}

    # Get count of displays
    dCount = len(displayInfo)
    # Get set of displays numbers where resource occurs
    dSet=[displayInfo[d]['displayNum'] for d in displayInfo if displayInfo[d]['rscCount']>0]

    # Using dSet, make a meaningful translation of where the resource occurs
    if len(dSet)==0:
        # Shouldn't happen, but report no panels if found nowhere
        resourceLocText = 'in no panels'
    elif len(dSet)==1:
        # If only one panel, say which
        resourceLocText = 'in the {} panel'.format(panelNamesDict[dCount][dSet[0]])
    elif len(dSet)==dCount:
        # If the resource occurs in all panels, simply say so
        resourceLocText = 'in all panels'
    elif dCount>=4 and dCount-len(dSet)<=float(dCount)/3.0:
        # Special handling for it might be easier to say where they ARE NOT
        anti_dSet=list(set(range(1,dCount+1))-set(dSet))
        anti_locList= ', '.join([panelNamesDict[dCount][dNum] for dNum in anti_dSet]) # notice all but last are added
        resourceLocText = 'in {} panels: all but the {} panel(s)'.format(len(dSet),anti_locList)
    else:
        # Try just concetenating all the panels it occurs in otherwise,
        locListExceptLast= ', '.join([panelNamesDict[dCount][dNum] for dNum in dSet[:-1]]) # notice all but last are added
        # In final step, use "and" to connect final item
        resourceLocText = 'in {} panels: {} and {} '.format(len(dSet),locListExceptLast,panelNamesDict[dCount][dSet[-1]])
    return resourceLocText



# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Define an ABSTRACT FUNCTINO for doing the actual altering of resource types, with params passed
def updateAbstractResources(bundleXmlObj,resourceXsiType,resourceLabel,valueGetter,valueSetter,changePrompt,validateInput,
                            preSetup=None, subXsiType=None, sessionData=None): # setting none to
    print('{:{fillChar}{align}{width}}'.format('', fillChar='=', align='^', width=20))
    # KEEP TRACK OF ARGUMENTS NEEDED HERE
    # resourceXsiType... e.g. radarResourceData
    # resourceLabel... mixed-case friendly label, used in and to prefix messages about the specific resource update
    # subXsiType ... this is important, as it specifies what category of saved (if any) substitutions to look in or save to. By defualt, this can be the resourceXsiType, but it can be overriden with a DIFFERENT type if matches are shared with another resource type (EXAMPLE: precipRate resource types use icao as constraint values, as do radar resource types, so share those substitutions since they'll likely be the same)
    subXsiType = resourceXsiType if not subXsiType else subXsiType  # Defualts to resourceXsiType f subXsiType argument is empty
    # valueGetter FUNCTION... defined for each resourceType, uses resourceData xml pointer as only argument to get the relevant constraint values currently used. returns the value
    # valueSetter FUNCTION... defined for each resourceType, uses resourceData xml pointer AND new value as arguments to directly set the relevant constraint to its updated value. Returns nothing
    # preSetup FUNCTION... optional, allows for executing any functionality before rest of commands are run. Necessary for some


    # IMPLEMENT multiple pattern handling if resourceXsiType is passed in as a list
    if isinstance(resourceXsiType, str):
        # Then only one xsiType needs to be searched for
        rMatches = bundleXmlObj.findall('.//resourceData[@' + xsitype + '="{}"]'.format(resourceXsiType))
    elif isinstance(resourceXsiType, list):
        rMatches=[] # Initialie empty list
        for x in resourceXsiType:
            rMatches= rMatches + bundleXmlObj.findall('.//resourceData[@' + xsitype + '="{}"]'.format(x))
    else:
        # Only lists or strings supported at this point, so return error and exit otherwise
        print('UKNONWN RESOURCE XSI TYPE: {}'.format(resourceXsiType))
        return None

    if len(rMatches) == 0:
        print('{}: No resources found in "{}" bundle... Finished'.format(resourceLabel.upper(),
                                                                         bundleXmlObj.get('name')))
        return None

    # Otherwise, resources found, so proceed
    print('{}: Searching for {} resources to edit in bundle...'.format(resourceLabel.upper(), resourceLabel))
    # If provided, run setup function
    if preSetup:
        preSetup(sessionData)

    # INITIALIZE DICTIONARY to manage list of discovered and fixed resources to change
    rResources = OrderedDict() # Make it an OrderedDict to preserve order of added keys
    for r in rMatches:
        # Find the current value of the resourceData constraint, using a provied "valueGetter" function which is defined and passed for each resource type
        val = valueGetter(r) # needs to be only one value
        # Make a list of unique values
        if val not in list(rResources.keys()):  # Make list of unique radars
            rResources[val] = {'newVal': '', 'original': [], 'fixed': []}
        rResources[val]['original'].append(r)

    print('\t-->Found {} {} source(s)!'.format(len(rResources),resourceLabel))  # only searches immediate children
    # HERE IS OPPORTUNITY, GENERALLY, TO PROVIDE PRE-FIX MESSAGE OR TIPS (e.g. what to do next, how best to do it, etc.)... might want to have some kind of function which allows custom behavior depending on result of searches
    # Helpful message if more than one radar
    if len(rResources)>1 and (resourceXsiType=='radarResourceData' or subXsiType=='radarResourceData'):
        print('\t*TIP: Consider the panel layout (and radar locations) when assigning radar substitutions.')


    for n, val in enumerate(rResources):

        # Call a pair of functions to get a text description of where resources occur.
        displayInfo = getDisplayInfoForRsrcs(bundleXmlObj, rResources[val]['original'])
        resourceOccurenceText = translateDisplayInfo(displayInfo)

        # Use the subXsiType to consult sessionDate for any saved substitutions for current resource type and value
        ##subGuess = checkSubstitution(subXsiType, val, sessionData)
        # NEW FORM... provide displayInfo as well
        subGuess = checkSubstitution(subXsiType, val, sessionData,displayInfo)

        verboseprint('SUB GUESS: {}'.format(subGuess))

        # Get the current state of the autoAcceptGuesses flag (accepts VALID guesses without prompt if True)
        autoAccept = sessionData['runConfig']['autoAcceptGuesses']

        # For each resource, do a generic print of it
        #print '\t{}) {}'.format(n+1,val)
        # UPDATE: For each resource, do a generic print AND state where it's found
        print('\t{}) {}\t\t\t[{}]'.format(n+1,val,resourceOccurenceText))

        # SET UP INPUT AND VALIDATION LOOPS...
        goodVal = False  # boolean flag is false until good choice made
        retry=''
        while not goodVal:
            # Use resource-specific function for defining what prompt to change value looks like
            inputPrompt,useDefault = changePrompt(val, subGuess, sessionData)  # use resource-specific function for defining change prompt

            # Set auto(default) response to input, but check useDefault flag to accomodate special prompts which avoid it
            default = None if not useDefault else (subGuess if subGuess else None) # FINAL "None" can be val, but I didn't like that behavior

            # IMPLEMENT AN OVERRIDE to confirmation which automatically accepts substitution guesses WHEN AVAILABLE.
            if subGuess and autoAccept: # if autoAcceptGuess is enabled AND a guess is available
                print('\t***AUTO ACCEPT GUESS*** Using: "{}"'.format(subGuess))
                newVal = subGuess
            else:
                newVal = raw_input_default(inputPrompt, default) or default

            # Check if an input validation function was provided, otherwise accept value
            if not validateInput:
                goodVal = True
            else:
                newVal=validateInput(newVal, sessionData) # This syntax allows validateInput to modify/format the actual newVal, which is useful for responding to different scenarios of user input
                # HACKY option for resetting subGuess
                if newVal=='resetSubGuess':
                    newVal=subGuess=None
                # Check if newVal is good
                goodVal = True if newVal else False # if newVal successful, update goodVal flag to exit loop
                # Even if autoAcceptGuesses is set, we'll run into problems when the subGuess doesn't validate...
                # By tying autoAccept to goodVal, we can halt that feature if a guess does not validate. NOTE that we do NOT want to save this back to the sessionData variable, because we want to keep that original selection untouched for other resources
                autoAccept=goodVal


        # BEFORE testing for save substitution, call subguess AGAIN.
        #   - Reason: Initial substitutions from args provide subGuess but DID NOT SAVE to sessionData.
        #   - Checking subguess again forces script to realize that sessionData has no saved substitution yet
        subGuess = checkSubstitution(subXsiType, val, sessionData, displayInfo)
        # Update sessionData with guess for next time
        if not subGuess or (subGuess and subGuess != newVal):  # update sessionData with guess for next time
            verboseprint('SAVING SUBSTITUTION')
            saveSubstitution(subXsiType, val, newVal, sessionData)

        # Assign new value to change dictionary for current value
        rResources[val]['newVal'] = newVal

        # If we were provided a new value, iterate through dictionary for resources to be changed
        if len(rResources[val]['newVal']) > 0:
            for n in range(len(rResources[val]['original'])):
                r = rResources[val]['original'].pop(0)  # REMOVE FROM "TOP" OF STACK
                # ATTEMPT SET USING PASSED VALUESETTER FUNCTION. This function needs to accept resourceData xml AND new value, and directly modify the constraint in the xml object
                valueSetter(r,rResources[val]['newVal'])

                # Optional debugging message proves whether actual xml attribute was directly & successfully changed
                # verboseprint('NEW ATTRIB AFTER SWAP={}'.format(valueGetter(r)))
                # SKIP ABOVE UNLESS REALLY NEED, because makes calls to valueGetter even if verbose False

                # Append resourceXml object to list of 'fixed' resources, for record
                rResources[val]['fixed'].append(r)
                # BTW, don't need to remove resource from original list because pop already accomplished that
        print(('\t--> UPDATED {} {} resources to "{}" (from: "{}").'.format(len(rResources[val]['fixed']), resourceLabel,
                                                                           newVal, val)))


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~


# DEFINE FUNCTION which handle the changes to warnings
def updateWarningResources(bundleXmlObj,sessionData=None, warningUpdateType='CONUS'):
    # TRY TO USE abstract resource update function for heavy lifting, only specifying resource-specific arguments here
    resourceUpdateOptions = {}
    resourceXsiType = ['wwaResourceData', 'cwaspsResourceData'] # List form allows multiple patterns to be captured
    # Because our resourceXsiType is a list in this case, make sure to specify a valid key for use with substitutions
    subXsiType='wwaResourceData'
    resourceLabel = 'warnings'

    # STRATEGY NOTE: It's possible that to do customization we need to access a file at either /awips2/cate/etc/menus/warnings/index.html (BASE version) OR, if exists, /awips2/edex/data/utility/cave_static/site/${WFO}/menus/warnings/index.html (site override, HOWEVER this would be on EDEX machine and not accessible on LX... so instead for LX use caveData version at: /home/${user}/caveData/etc/site/${WFO}/menus/warnings/index.html  )
    # BUT if we go with a simpler option to use the whole conus list, let's just define the whole list of WFOs here that we could use for replacement.
    ALL_IDs = "KABQ, KABR, KAKQ, KALY, KAMA, KAPX, KARX, KBGM, KBIS, KBMX, KBOI, KBOU, KBOX, KBRO, KBTV, KBUF, KBYZ, KCAE, KCAR, KCHS, KCLE, KCRP, KCTP, KCYS, KDDC, KDLH, KDMX, KDTX, KDVN, KEAX, KEKA, KEPZ, KEWX, KFFC, KFGF, KFGZ, KFSD, KFWD, KGGW, KGID, KGJT, KGLD, KGRB, KGRR, KGSP, KGYX, KHGX, KHNX, KHUN, KICT, KILM, KILN, KILX, KIND, KIWX, KJAN, KJAX, KJKL, KKEY, KLBF, KLCH, KLIX, KLKN, KLMK, KLOT, KLOX, KLSX, KLUB, KLWX, KLZK, KMAF, KMEG, KMFL, KMFR, KMHX, KMKX, KMLB, KMOB, KMPX, KMQT, KMRX, KMSO, KMTR, KOAX, KOHX, KOKX, KOTX, KOUN, KPAH, KPBZ, KPDT, KPHI, KPIH, KPQR, KPSR, KPUB, KRAH, KREV, KRIW, KRLX, KRNK, KSEW, KSGF, KSGX, KSHV, KSJT, KSLC, KSTO, KTAE, KTBW, KTFX, KTOP, KTSA, KTWC, KUNR, KVEF, PAFC, PAFG, PAJK, PGUM, PHFO, TJSJ, NSTU"

    # As part of the warning update, use a simple lookup to get the four-letter value of user's localization from above (we can't rely on prefixing with K for 100% of sites)...
    def preSetup(sessionData):
        if not 'wfo4' in list(sessionData.keys()):
            wfo4=[s.strip() for s in ALL_IDs.split(',') if sessionData['loc'].upper() in s]
            if len(wfo4)==1:
                wfo4=wfo4[0]
            else: # zero results or non-unique (multiple) found
                good_wfo4Match = False
                while not good_wfo4Match:
                    wfo4 = input('\t>> PLEASE ENTER your CCCC four-letter office identifier (e.g. KOAX): \n').upper()
                    confirm = input('\t>> Press "y" and ENTER to CONFIRM {} is your four-letter site identifier: '.format(
                        wfo4))
                    good_wfo4Match = True if confirm.lower() == 'y' else False
            sessionData['wfo4']=wfo4 # prevent it from being a list


    def valueGetter(resourceXmlObj):
        return resourceXmlObj.find('.//mapping[@key="officeid"]/constraint').get('constraintValue')

    def valueSetter(resourceXmlObj, newVal):
        # For setting list of WFO IDs in warning resource. NOTE: no quoting needed, since attributes already get quoted
        resourceXmlObj.find('.//mapping[@key="officeid"]/constraint').set('constraintValue', newVal.strip('"'))

        # WE ALSO SHOULD be correcting the warning products label "Local & Regional Warnings" (did in previous version) but skipping here for now. Not absolutely crucial

    def changePrompt(val, subGuess, sessionData):  # Allow additional args that might not be used
        inputPrompt = '\t>> Type all (3-letter) WFO ID\'s to show for warning polygon displays '\
            '(e.g. OAX, GID, LBF ...).\n\t**NOTE: These will be converted to 4-letter CCCC ID\'s. '\
                'Can also type "ALL" to show every site\n\t[EDIT/ENTER]: '
#        inputPrompt = '\t>> Type all 4-letter CCCC originating office IDs, seperated by commas, for warning polygon displays (e.g. KOAX, KGID, KLBF ...)\n\t[EDIT/ENTER]: ' # RETIRING in favor of asking for comma seperated WFO list
        useDefault = True # Should this prompt allow default input (e.g. substitution guesses?)
        return inputPrompt, useDefault

    def validateInput(inputVal, sessionData):
        # First, was a valid string entered?
        if not inputVal:
            print('!\tINVLAID ENTRY! Try again...')
            return False
        elif inputVal.upper()=='ALL':
            # Next, if user entered "ALL", set warning sites to full CONUS set
            warningSites = ALL_IDs
        else:
            # GOAL: IF user entered wfo list, ensure valid wfos, and translate to valid cccc
            # FIRST: clean up input
            wfo3set=[s.upper().strip().strip('"') for s in inputVal.split(',')] # Important also to strip quotes before validation

            # GO DIRECT TO accepting input IF all values are 4-letter cccc's alread:
            #   --> Rationale: if we retrieved a list of cccc's straight from localization, we don't bother checking
            all_cccc = all(len(x)==4 for x in wfo3set)
            if all_cccc:
                verboseprint('\t--> ACCEPTING WARNING SITES AS PROVIDED!')
                warningSites = ', '.join(wfo3set) # Join the input (i.e. "wfo3set") list
            else: # PROCEED with validation steps
                # HANDLE SAN JUAN: If "SJU" typed, switch to "JSJ" to make match CCCC
                wfo3set = ['JSJ' if s == 'SJU' else s for s in wfo3set]

                # PREPARE reference list of ALL sites for comparison
                ALL_RefSet=[s.strip().strip('"') for s in ALL_IDs.split(',')]

                # Use 3-letter wfo set to find matching 4-letter CCCC in all-site reference list
                wfo4set = [xs for xs in ALL_RefSet if any(s in xs for s in wfo3set)]
                # Find any entries from wfo3set which were not matched to the all-site reference list
                errors = [s for s in wfo3set if not any(s in xs for xs in ALL_RefSet)]
                if not len(errors)==0:
                    print('!\t ERROR: The following WFO IDs are not valid: {}'.format(', '.join(errors)))
                    return False
                else:
                    warningSites=', '.join(wfo4set)
        # Following CAVE menu practice, include local site, then all sites (local site will therefore be repeated once), BUT ONLY if it doesn't already contain the primary wfo4 twice
        while warningSites.count(sessionData['wfo4'])<2:
            warningSites='{}, {}'.format(sessionData['wfo4'],warningSites.strip('"'))
        return warningSites

    # Make call to abstract resource update function, passing resource-specific arguments
    updateAbstractResources(bundleXmlObj, resourceXsiType, resourceLabel, valueGetter, valueSetter, changePrompt,
                            validateInput, subXsiType=subXsiType, preSetup=preSetup, sessionData=sessionData)


# DEFINE FUNCTION for altering radar resources
def updateRadarResources(bundleXmlObj, sessionData=None): # Default for sessionData is false to allow easy tests for its presence
    # TRY TO USE abstract resource update function for heavy lifting, only specifying resource-specific arguments here
    resourceUpdateOptions={}
    resourceXsiType='radarResourceData'
    resourceLabel='radar'

    def valueGetter(resourceXmlObj):
        return resourceXmlObj.find('.//mapping[@key="icao"]/constraint').get('constraintValue')

    def valueSetter(resourceXmlObj,newVal):
        resourceXmlObj.find('.//mapping[@key="icao"]/constraint').set('constraintValue', newVal)

    def changePrompt(val, *args): # Allow additional args that might not be used
        inputPrompt = '\t>> Type radar to substitute for {}\n\t[ENTER to accept]: '.format(val)
        useDefault = True # Should this prompt allow default input (e.g. substitution guesses?)
        return inputPrompt, useDefault
        ###return inputPrompt


    def validateInput(inputVal, *args):
        # IF is valid input, then only real checks are to see if it's four characters long, also can convert to lower
        if inputVal and len(inputVal)==4 and inputVal.isalpha():
            return inputVal.lower() # Final act is to ensure icao is lower-case
        else:
            print('!\tERROR: icao needs to be a four-character input (rejected: "{}")'.format(inputVal))
            return False # Return False to trigger new input prompt

    # Make call to abstract resource update function, passing resource-specific arguments
    updateAbstractResources(bundleXmlObj,resourceXsiType,resourceLabel,valueGetter,valueSetter,changePrompt,validateInput,sessionData=sessionData)



# DEFINE FUNCTION for altering satellite resources
def updateSatResources(bundleXmlObj,
                       sessionData=None):  # Default for sessionData is false to allow easy tests for its presence
    # TRY TO USE abstract resource update function for heavy lifting, only specifying resource-specific arguments here
    resourceUpdateOptions = {}
    #resourceXsiType = 'satBlendedResourceData' # ORIGINAL, when only satBlendedResourceData seemed needed to capture all satellite resource groups
    # [MAY 2020] Expanding resourceXsiTypes for sat data to include new "trueColor..." group type
    resourceXsiType = ['satBlendedResourceData', 'trueColorResourceGroupData'] # List form allows multiple patterns to be captured
    # Because our resourceXsiType is a list in this case, make sure to specify a valid key for use with substitutions
    subXsiType='satBlendedResourceData'
    resourceLabel = 'satellite'


    # DEFINE tuples for selecting appropriate satellite sectors
    #   --> UPDATE: Need to make any occurence of 'East CONUS" into "ECONUS" instaed because of GEOS-16.
    #       THIS IS SPECIFIC to current year and needs to be made more generic/robust
    ###sectorOptions = {'West CONUS': 'West CONUS', 'East CONUS': 'East CONUS', 'BOTH': 'West CONUS,East CONUS'}
    sectorOptions = {'West CONUS': 'West CONUS', 'East CONUS': 'ECONUS', 'BOTH': 'West CONUS,ECONUS'}
    elementMapping = {'Imager 11 micron IR': 'CH-13-10.35um', # Map old IR to new clean IR
                     'Imager 6.7-6.5 micron IR (WV)': 'CH-09-6.95um', # Map old WV to new mid-level WV
                     'Imager Visible': 'CH-02-0.64um'} # Map old visible to new red visible
    # Finally, since colormaps can be vastly different for new vs. old GOES, check for new colormaps
    #   --> Ideally, would consult localization (goesrCMI-ImageryStyleRules.xml seems like the best)
    #   --> As quick patch, specify mapping in dictionary here for the bands we're substituting
    cmapMapping = {'CH-13-10.35um': 'Sat/IR/CIMSS_IR',
                   'CH-09-6.95um': 'Sat/WV/CIMSS_WV',
                   'CH-02-0.64um': 'Sat/VIS/ZA (Vis Default)'}

    # Next define a special satellite function to find the appropriate physical elements based on the sector value(s)
    def getPhysicalElement(origElement,sectorVal):
        # Kind of hard-coding this for now, BUT IT SHOULD BE MORE ROBUST/DYNAMIC
        pElemList = []  # initialize list for storing elements
        # Try splitting the sectorVal to see if it has multiple items
        sectorList=sectorVal.split(',') # automatically produces list type, even for one item
        for s in sectorList:
            if 'West CONUS' in s:
                # ASSUME the original element value is the "legacy" GOES element, and just assign
                pElemList.append(origElement)
            elif 'ECONUS' in s:
                # [MAY 2020] Already our assumption that we have a defined set of legacy channels appears outdated
                # --> NOT A PERMANENT FIX!!!!!
                # --> Wrap mapping in a "try" statement...
                # --> If it fails, just assign the origElement AS-IS (this might actually serve us well going forward, when mapping from an old to new element is no longer needed)
                try:
                    # Use hte elementMapping dictionary to look up the new type of element
                    pElemList.append(elementMapping[origElement])
                except:
                    print(('Satellite phys element mapping failed for "{}"... leaving element AS-IS'.format(origElement)))
                    pElemList.append(origElement)
        # NOW use JOIN to make output string, which will comma-separate elements if more than one exists
        pElemString = ','.join(pElemList)
        return pElemString



    def valueGetter(resourceXmlObj):
        # NOTE: Although there are actually two nested sectorID mappings, I believe they are hte same
        return resourceXmlObj.find('.//mapping[@key="sectorID"]/constraint').get('constraintValue')

    def valueSetter(resourceXmlObj, newVal):
        # satBlendedResourceData is a strange type.
        #   --> Has its own metadataMap directly under it (not within a resource) that gets changed
        #   --> ALSO has pretty much same metadataMap within a nested resource
        #   --> Therefore, need to explicitly search for the nested resource metadata and change those too
        # Because of GOES-R differences from older GOES, value setting for satellite data involves:
        #   --> setting the physical element depending on if old GOES, new GOES, or both
        #   --> setting constraint type to "IN" if we have multiple physicl elements because of "both"

        # TEST the sectorID to determine if we have a comma, indicating two sat sectors
        twoSats=',' in newVal
        # IF we have two sectors, we need to change the constraint type also
        newSectorConstraintType = 'IN' if twoSats else 'EQUALS'

        # SPECIAL SYNTAX FOR SATELLITE: find all descendant occurrences of 'sectorID' and set values
        for r in resourceXmlObj.findall('.//mapping[@key="sectorID"]/constraint'):
            r.set('constraintValue', newVal) # set new sector value
            r.set('constraintType', newSectorConstraintType) # set new constraint type

        # NEXT, LOOK UP the physical element in use
        origElement=resourceXmlObj.find('.//mapping[@key="physicalElement"]/constraint').get('constraintValue')
        # Passing the new sector value(s) AND the current physical element, determine what physical element should be
        newElemString = getPhysicalElement(origElement, newVal)
        # TEST to see if we have a comma
        twoElems = ',' in newElemString
        # IF we have two elements, we need to change the constraint type also
        newElemConstraintType = 'IN' if twoSats else 'EQUALS'

        # SPECIAL SYNTAX FOR SATELLITE: find all descendant occurrences of 'sectorID' and set values
        for r in resourceXmlObj.findall('.//mapping[@key="physicalElement"]/constraint'):
            r.set('constraintValue', newElemString)  # set new physical element value
            r.set('constraintType', newElemConstraintType)  # set new constraint type

        verboseprint('newSectors {} {}'.format(newSectorConstraintType, newVal))
        verboseprint('newElements {} {}'.format(newElemConstraintType, newElemString))

        # NEXT, look up colormaps to use based on physical element
        # [MAY 2020] THIS DESPERATELY REQUIRES UPDATING... 
        if 'ECONUS' in newVal: # Only look up new colormap if we're using new GOES ('ECONUS' sector)
            # [MAY 2020] WRAPPING IN TRY because our lookup is outdated (perhaps not even necessary)
            try:
                newCmap = cmapMapping[newElemString]
                # Now set the new colormap in appropriate attribute/tag
                #   --> For satellite, there is actually one colormap within resource/loadProperties...
                resourceXmlObj.find('.//colorMapParameters[@colorMapName]').set('colorMapName',newCmap)
                #   --> ... AND another in the PARENT resource (very strange) which we need to modify
                try:
                    rElem = resourceXmlObj.xpath('ancestor::resource')
                    rElem[0].find('.//colorMapParameters[@colorMapName]').set('colorMapName',newCmap)
                except:
                    verboseprint('No colormapParameters found within parent resource of satBlendedResourceData')
            except:
                verboseprint('Colormap lookup failed (perhaps outdated assumptions about old-> new mapping), leaving AS-IS')

        # FUTURE WORK!
        #   1) This SHOULD really change also the resource legend label to be consistent
        #   2) This should also change the colormap if a better one is known, e.g. for GOES-R series


    def changePrompt(val, subGuess, sessionData, retryOption=False):  # Require both val and subGuess args, even if not used
        # For satellite, use a menu selection style prompt when no prior substitution history is known. But, AVOID this lengthy prompt in favor of a simpler confirmation if a guess IS available.
        if not retryOption and subGuess:  # subGuess will be false if not available. ALSO check if this is a retry passed from validation failure
            inputPrompt='\t>> CONFIRM SUBSTITUTION, is this your primary satellite? [EDIT/ENTER]: '
            useDefault = True  # Should this prompt allow default input (e.g. substitution guesses?)
        else:
            leadIn='\t* NOTICE: To update satellite products, which satellite sector(s) below best cover your WFO?'
            choices = '\n'.join('\t\t{}) {}'.format(i + 1, s) for i, s in enumerate(sectorOptions))
            closing='\t>> Type number corresponding to choice above to select sector.\n\tSector # [ENTER to accept]: '
            inputPrompt='\n'.join([leadIn,choices,closing])
            useDefault=False  # Should this prompt allow default input (e.g. substitution guesses?)
        return inputPrompt, useDefault
        ###return inputPrompt

    def validateInput(inputVal, *args):
        # For satellite, we have a more interesting validation challenge. The input to validate either prompted to user to confirm that a text string represented their sectorId, OR it asked them to pick a number from a list of sectors presented to them.  Validation must consider both these scenarios
        # FIRST, check if the input is a number, and morover within the range of choices  allowed
        if str(inputVal).isdigit():
            if int(inputVal) > 0 and int(inputVal) <= len(sectorOptions):
                # If the choice is valid, CONVERT IT to the actual sector string for constraint substitution
                newsector = sectorOptions[list(sectorOptions.keys())[int(inputVal) - 1]]
                return newsector
            else:
                print('!\tINVALID CHOICE! Must be number between {} and {}'.format(1, len(sectorOptions)))
                return False
        # SECOND check is to see if input is actually a valid name from the allowed sector options
        elif str(inputVal) in list(sectorOptions.values()):
            return inputVal # just return if so
        else:
            # In all other failure cases, force return to the sector selection choices. Do this in sneaky way by resetting subGuess to none, which will trigger return to full sector menu-style prompt
            print('!\tINVALID INPUT...')
            return 'resetSubGuess'

    # Make call to abstract resource update function, passing resource-specific arguments
    updateAbstractResources(bundleXmlObj, resourceXsiType, resourceLabel, valueGetter, valueSetter, changePrompt,
                            validateInput, subXsiType=subXsiType, sessionData=sessionData) # Now including subXsiType


# DEFINE FUNCTION for altering precipRate resources
def updatePrecipRateResources(bundleXmlObj, sessionData=None): # Default for sessionData is false to allow easy tests for its presence
    # TRY TO USE abstract resource update function for heavy lifting, only specifying resource-specific arguments here
    resourceUpdateOptions = {}
    resourceXsiType = 'precipRateResourceData'
    # Share subGuess lookup with radarResourceData, since icao substitutions are likely the same
    subXsiType='radarResourceData'
    resourceLabel = 'preciprate'

    def valueGetter(resourceXmlObj):
        # NOTE: although a different resource type than radar, precipRate's primary mapping constraint is also icao's
        return resourceXmlObj.find('.//mapping[@key="icao"]/constraint').get('constraintValue')

    def valueSetter(resourceXmlObj, newVal):
        resourceXmlObj.find('.//mapping[@key="icao"]/constraint').set('constraintValue', newVal)

    def changePrompt(val, *args):  # Allow additional args that might not be used
        inputPrompt = '\t>> Type precipRate radar to substitute for {} [ENTER to accept]: '.format(val)
        useDefault = True  # Should this prompt allow default input (e.g. substitution guesses?)
        return inputPrompt, useDefault

    def validateInput(inputVal, *args):
        # Only real checks are to see if it's four characters long, also can convert to lower
        if len(inputVal) == 4 and inputVal.isalpha():
            return inputVal.lower()  # Final act is to ensure icao is lower-case
        else:
            print('!\tERROR: icao needs to be a four-character input (rejected: "{}")'.format(inputVal))
            return False  # Return False to trigger new input prompt

    # Make call to abstract resource update function, passing resource-specific arguments
    updateAbstractResources(bundleXmlObj, resourceXsiType, resourceLabel, valueGetter, valueSetter, changePrompt,
                            validateInput, subXsiType=subXsiType, sessionData=sessionData)



# DEFINE FUNCTION for altering ffmp resources
def updateFfmpResources(bundleXmlObj, sessionData=None): # Default for sessionData is false to allow easy tests for its presence
    # TRY TO USE abstract resource update function for heavy lifting, only specifying resource-specific arguments here
    resourceUpdateOptions = {}
    resourceXsiType = 'ffmpResourceData'
    # Share subGuess lookup with radarResourceData, since icao substitutions are likely the same
    subXsiType='radarResourceData'
    resourceLabel = 'ffmp'

    # FFMP site/dataKey can be either icao(radar) or the following non-icao types:
    nonIcaoTypes = ['hpe','bhpe','mrms'] # MAYBE MORE? Could in theory get from localization as well

    # For ffmp, we don't really want the user to change non-icao data/siteKeys. Without more complex logic to skip the prompt, can help user by immediately pre-populating subGuess with these values, to prevent mistake in typing...
    def preSetup(sessionData):
        newSubs=0
        for t in nonIcaoTypes:
            if not checkSubstitution(subXsiType,t,sessionData):
                if newSubs==0:
                    print('ONE-TIME FFMP SETUP: Committing non-icao substitutions...')
                saveSubstitution(subXsiType,t,t,sessionData)
                newSubs+=1
        if newSubs>0:
            print('\t... finished with initial FFMP setup.\n')

    def valueGetter(resourceXmlObj):
        # We actually have two locations to check, dataKey and siteKey. USUALLY are the same, BUT not for XMRG
        try:
            # Not all FFMP resources have dataKey mapping constraint (e.g. XMRG types fail)
            dataKey = resourceXmlObj.find('.//mapping[@key="dataKey"]/constraint').get('constraintValue')
        except:
            # If failure, have to get from resourceData tag's dataKey attribute
            dataKey = resourceXmlObj.get('dataKey')
        siteKey = resourceXmlObj.find('.//mapping[@key="siteKey"]/constraint').get('constraintValue')
        if not len(set([dataKey,siteKey]))==1:
            print(('*** POTENTIAL ISSUE WITH FFMP, site and data key different ("{}", "{}").'.format(siteKey,dataKey)))
        # In any case, pick siteKey as return value (THIS TURNS OUT TO BE BETTER THAN dataKey, e.g. for XMRG cases).
        return siteKey # assign siteKey as return value, since SEEMS to be the more reliable constraint map

    def valueSetter(resourceXmlObj, newVal):
        # Value setting for ffmpResourceData is fairly involved... need to update dataKey and siteKey in both resourceData attributes, and in sub-elements under mapping. Fortunately, their values are USUALLY the same (actually, with XMRG dataKey, we have issue where different from siteKey. In this case, we won't even have a dataKey mapping constraint. If so, opt only to change siteKey
        # TRY Update dataKey mapping constraint and resourceData attributes in one go
        try:
            resourceXmlObj.find('.//mapping[@key="dataKey"]/constraint').set('constraintValue', newVal)
            resourceXmlObj.set('dataKey', newVal)
        except:
            print('*** No dataKey mapping constraint for this FFMP resource. Skipping dataKey changes')
        # Update siteKey mapping constraint and resourceData attributes
        resourceXmlObj.find('.//mapping[@key="siteKey"]/constraint').set('constraintValue', newVal)
        resourceXmlObj.set('siteKey', newVal)

    def changePrompt(val, *args):  # Allow additional args that might not be used
        if val in nonIcaoTypes:
            # Have different message encouraging NO CHANGE for non-icao ffmp dataKeys
            inputPrompt = '\t>> Confirm FFMP dataKey for this bundle (** RECOMMEND KEEPING as "{}"): \n\t[ENTER to accept]: '.format(val)
        else:
            inputPrompt = '\t>> Type FFMP radar key to substitute for "{}"\n\t[EDIT/ENTER]: '.format(val)
        useDefault = True  # Should this prompt allow default input (e.g. substitution guesses?)
        return inputPrompt, useDefault

    def validateInput(inputVal, *args):
        # For ffmp, besides icaos we should allow the following options
        if inputVal in nonIcaoTypes:
            return inputVal
        # Only real checks are to see if it's four characters long, also can convert to lower
        if len(inputVal) == 4 and inputVal.isalpha():
            return inputVal.lower()  # Final act is to ensure icao is lower-case
        else:
            print('!\tINVALID INPUT: source dataKey must be four-character icao (e.g. KTLX) or one of following: needs to be a four-character input (rejected: "{}")'.format(inputVal))
            return False  # Return False to trigger new input prompt

    # Make call to abstract resource update function, passing resource-specific arguments
    updateAbstractResources(bundleXmlObj, resourceXsiType, resourceLabel, valueGetter, valueSetter, changePrompt,
                            validateInput, preSetup=preSetup, subXsiType=subXsiType, sessionData=sessionData)



# DEFINE FUNCTION for altering map resource
# JULY 2019 UPDATE: Enable updating of mapScale attribute with a new value... this is to support special cases where the "standard" scales used in bundles do not match those at a WFO, and different mappings have been made at start of script
def updateMaps(bundleXmlObj, sessionData=None):
    print('{:{fillChar}{align}{width}}'.format('',fillChar='=',align='^',width=20))
    # Note: passing sessionData for scaleDict and also for possiblity of saving upadte notes. No need to consult sessionData for prior substition guesses, since for each scale we already know the exact substitutions needed
    if not sessionData or 'scaleDict' not in sessionData or not any(sessionData['scaleDict']):
        print('!!!ALERT: No scales info provided... skipping mapScale updates to bundle')
        return None
    mapResources={}
    # Search for display elements with mapScale, mapCenter attributes to change. NOTE: All displays in one bundle SHOULD have a single common scale, but we'll make this flexible enough to accomodate the possibility that this isn't true
    displayMatches= getBundleDisplays(bundleXmlObj) # We have a convenient function for this
    for d in displayMatches:
        dScale=getDisplayScale(d) # Again, we have a convenient function to use here
        if dScale not in list(mapResources.keys()): # Make list of unique scales
            mapResources[dScale] = {'newScale':'', 'original':[],'fixed':[]}
            ## Doesn't accomplish anything, but for consistency, assign current scale to "newScale" since it won't change
            #mapResources[dScale]['newScale']=dScale
            # ACTUALLY, commenting out above code because we now WILL allow scale to change. Inserting new value is just a matter of consulting the scaleDict... (NOTE: this works and is easy, but not sure if there's a better convention (following example of pre-specifying readars) for setting up this dictionary with "orig" and "new" values beforehand... but since it works as-is just fine, letting it be. 
            # MAY 2021 UPDATE: Wrapping the retrieval in a try/except block, mainly for gentler error-handling
            # --> Although we shouldn't get here without having a match between the needed bundle/display scale and our "scaleDict", it's possible
            # --> This try block fixes nothing, but does explain what went wrong before quitting
            try: 
                mapResources[dScale]['newScale']=sessionData['scaleDict'][dScale]['scaleName']
            except:
                print('ERROR! Bundle scale "{}" has no matching key in the sessionData "scaleDict" (available keys below)! EXITING!'.format(dScale))
                print((list(sessionData['scaleDict'].keys())))
                exit()          
       
        # Below appends each display to a list of resources (for given dScale) which will require updating
        mapResources[dScale]['original'].append(d)
        
    for n, mapScale in enumerate(mapResources):
        # PREP helpful note to insert about scale being updated
        scaleChangeNotice=''
        # Test if current mapScale is identical  to "new" scale value
        if mapScale!=mapResources[dScale]['newScale']:
            scaleChangeNotice='(now "{}") '.format(mapResources[dScale]['newScale'])
        # DON'T PROMPT, just inform and update
        print(('MAPS: Will update map coordinates for "{}" scale {}(used in {} displays in this bundle)...'.format(mapScale,scaleChangeNotice,len(mapResources[mapScale]['original']))))
        # JULY 2019 note: not changing below code at all, becasue FYI it already will retrieve ALL needed dict keys for the scale, including new mapScale key
        newMapInfo = sessionData['scaleDict'][mapScale] 

        for i in range(len(mapResources[mapScale]['original'])):
            d = mapResources[mapScale]['original'].pop(0)  # REMOVE FROM "TOP" OF STACK
            # (NEW JULY 2019) ACCOMODATE ability to change mapScale, important for WFOs who do not use "standard" scales. IN FACT, BY DEFAULT UPDATE IT, although this won't actually change anything in ~95% of cases where original scale is available and matched with same value in scalesInfo
            # Directly modify the scale attribute of the display element d
            d.set('scale',newMapInfo['scaleName']) # <-- JULY 2019 ADDITION
            # Directly modify the mapCenter attribute of the display element d
            d.set('mapCenter',newMapInfo['mapCenter'])
            # We ALSO need to set the gridGeometry tags to the correct value. There should be one gridGeometery in the display but we'll access it in a findall loop for added insurance
            for g in d.findall('.//gridGeometry'):
                # lxml does not permit a single element to be placed in multiple locations. It MUST be 'deepcopied'...
                geomElemCopy=deepcopy(newMapInfo['geomElement']) # create COPY of gridGeometry tag for each display
                g.getparent().replace(g,geomElemCopy)
            mapResources[mapScale]['fixed'].append(d)
            # Finally, will ALSO need to update instances of Local CWA Boundary which have a wfo-specific constraint, e.g. <constraint>wfo = 'OUN'</constraint> (only aware of "Local CWA Boundary" at the moment, may discover others later)
            wfoMap = d.xpath('.//constraint[contains(text(),"wfo = ")]') # NOT SURE if this only finds one match
            for w in wfoMap:
                # If found, set text for this map constraint to reflect localization/CWA choice
                w.text="wfo = '{}'".format(sessionData['loc'])
                verboseprint('Changed a cwa-specific map contraint to "{}"'.format(w.text))
        print(('\t-->UPDATED {} maps with scale "{}" to new coordinates.'.format(len(mapResources[mapScale]['fixed']),mapScale)))
            # Don't need to remove from original list because pop already accomplished that
    print('\t... map updates complete!')



# MAKE A FUNCTION which will determine the likely changes needed?
def detectNeededChanges(bundleXmlObj, sessionData=None):
    print('\nDETECTING needed changes for "{}"...'.format(bundleXmlObj.get('name')))
    # Call function to update maps. ALL Bundles have maps
    updateMaps(bundleXmlObj, sessionData)

    # CHECK FOR RADARS... almost all radars need to be customized for WFO. Call function for doing this:
    updateRadarResources(bundleXmlObj, sessionData)

    # CHECK FOR SATELLITE... need to determine if satellite sector needs a fix... e.g. East CONUS or West CONUS. Call function for doing this:
    updateSatResources(bundleXmlObj, sessionData)

    # ALSO IMPORTANT!! WE HAVE TO MODIFY WARNING PRODUCTS because they are filtered to regional officeids...
    updateWarningResources(bundleXmlObj, sessionData)

    # (Mostly for HYDRO Procedures) Check for precipRate resource types
    updatePrecipRateResources(bundleXmlObj, sessionData)

    # (Mostly for HYDRO Procedures) Check for ffmp resource types
    updateFfmpResources(bundleXmlObj, sessionData)

    # Print simple dividing line
    print('{:{fillChar}{align}{width}}'.format('',fillChar='=',align='^',width=20))





# ======================================================================

# DEFINE a function which can handle bundle renaming.
# Guiding ideas... above all, give user chance to edit. As far as prompting with a good guess, generally the only wfo-specific elements in title are radar, or to a lesser extent the satellite.  To address radar, query the bundle to see if a unique icao is being used, and change the title to reflect that. For GOES, since sector might change, simplest solution is to eliminate any specific GOES reference (possible but more superflous also to try to reference the GOES # based on sector).
def renameBundle(bundleXmlObj,sessionData=None):

    # Get bundle name
    origBundleName=bundleXmlObj.get('name')
    bundleName=origBundleName # To start, they are equal

    # Get the current state of the autoAcceptGuesses flag (accepts VALID guesses without prompt if True)
    try: # "try" is KIND of unnecessary, since sessionData can always be passed and assignment should succeed
        autoAccept = sessionData['runConfig']['autoAcceptGuesses']
    except:
        autoAccept=False

    # Query bundle name for any radars
    patternExclusions=['topo'] # Exclude words that might match icaoPatternbelow
    # Bundle rename guidelines:
    # 1) Look for 4-letter character starting with k or t, i.e. ICAO strings
    # 2) Exclude matches in the 'patternExclusion' list (like 'topo') (NOTE: this uses lookahead "(?!...|...)" bracket)
    # 3) Matches must not be preceded OR followed by word character (uses "(?<!\w)" and "[^\w]" respectively)
    icaoPattern=r'((?<!\w)(?!{})[kt][a-z]{{3}})[^\w]'.format('|'.join(patternExclusions)) # double {{}} format for hiding literal {} from format call

    icaoMatch = re.search(icaoPattern,bundleName,re.IGNORECASE)
    if icaoMatch: # Did we find any icaos?
        icao=icaoMatch.group(1)
        verboseprint('FOUND {} AS POSSIBLE RADAR in BUNDLE'.format(icao))
        # Look at the bundle in question to determine what unique radars are there. ALTERNATE: if find icao, check substitutions used for this in sessionData?
        allIcao=set([match.get('constraintValue') for match in bundleXmlObj.findall('.//mapping[@key="icao"]/constraint')])
        if len(allIcao)==1: # If one unique radar, title sub is easy fix
            newicao=list(allIcao)[0]
            print(('\t-->BUNDLE RENAME HELPER: swapping "{}" with "{}"'.format(icao,newicao)))
            # DO the actual substitution
            bundleName=bundleName.replace(icao,newicao)
            verboseprint(bundleName)
        else: # radars found but no unique replacement option
            print('BUNDLE RENAME ERROR: cannot find unique icao for name in bundle')

    # Next, query bundle for any reference to GOES
    goesPattern = r'GOES\S+'  # GOES followed any character until first whitespace
    goesMatch = re.search(goesPattern, bundleName, re.IGNORECASE)

    if goesMatch:  # Did we find any icaos?
        goesName = goesMatch.group()
        verboseprint('FOUND {} AS POSSIBLE satellite in BUNDLE'.format(goesName))
        newGoesText='GOES' # Can use a function to get this, or just specify a generic substitute
        print(('\t-->BUNDLE RENAME HELPER: Making "{}" into generic form "{}"'.format(goesName,newGoesText)))
        bundleName=bundleName.replace(goesName,newGoesText)

    # At very end, prompt user to ensure they approve of name. Here, use special
    # IMPLEMENT AN OVERRIDE to confirmation which automatically accepts substitution guesses WHEN AVAILABLE.
    if autoAccept and bundleName:  # if autoAcceptGuess is enabled AND (extra security) bundleName not nothing
        print('***AUTO ACCEPT NEW NAME*** Using: "{}"'.format(bundleName))
        newBundleName=bundleName
    else:
        print(('>>\tEdit or Accept new name (below) for "{}" bundle?'.format(origBundleName)))
        newBundleName=raw_input_default('\t[EDIT/ENTER]: ',default=bundleName) or bundleName
        print('** BUNDLE RENAMED to: {}'.format(newBundleName))

    # ACTUALLY SET the new bundle name
    bundleXmlObj.set('name',newBundleName)


# ======================================================================
# Would be useful to define some cleanup functions.

# DEFINE A FUNCTION which deletes a bundle

# DEFINE A FUNCTION which prompts user whether to keep tdwr-only radar bundles. Bundles designed for 88Ds do not retain full functionality when converted to TDWR
def removeTDWRBundles():
    test=''

# DEFINE A FUNCTION which handles all cleanup
def cleanupMaster():
    # Make calls to all cleanup
    removeTDWRBundles()


# ======================================================================

# WOULD BE USEFUL to have a function which returns the simple result of inventorying a bundle's displays, as a list of lists
def getAllDisplaysInventory(displays):
    displaySet=[] # Initialize list to store display info
    for i, d in enumerate(displays):
        displaySet.append([])
        # Get the scale info as a useful display element
        dScale = getDisplayScale(d) # They should all be the same, so don't worry about overwriting
        disp_rsrcs=inventoryDisplay(d)
        for d in disp_rsrcs:
            verboseprint('{}: {}'.format(d,disp_rsrcs[d]))
            if not 'MAP:' in disp_rsrcs[d]['info']:
                displaySet[i].append(disp_rsrcs[d]['info'])
    return [displaySet, dScale]




# DEFINE FUNCTION for plain list format printing of display elements
def printDisplaySimple(displays):
    for i, d in enumerate(displays):
        # Get the scale info as a useful display element
        dScale=getDisplayScale(d)
        print('---------NEW {} DISPLAY ({} of {})---------'.format(dScale, i+1, len(displays)))
        disp_rsrcs=inventoryDisplay(d)
        verboseprint('\n')

        print('NOW SHOWING ONLY THE DISPLAY LABELS:')
        for d in disp_rsrcs:
            # USE THIS STATEMENT TO PRINT ALL
            #if True:
            # USE THIS OPTION TO FILTER OUT MAPS
            if not 'MAP:' in disp_rsrcs[d]['info']:
                print(disp_rsrcs[d]['info'])
        print('\n\n')


# ======================================================================
# EXTRA utiltity functions which help determine other procedure dependencies.
#   - Useful for deciding what needs to be copied to util tree (manually or by script with LFI)
#   - Kind of tangential to bundle modification, yet related functionality in terms of component analysis

# DEFINE function which finds all the unique colormaps
def getUniqueColormaps(rootXMLObj):
    # NOTE: Expect that this is passed root xml for procedure doc, but will work with/analyze children of tag at any level
    # Get all xml tags for colorMapParameters with colorMapName attribute
    cmapMatches = rootXMLObj.findall('.//colorMapParameters[@colorMapName]')
    # Make unique set of actual colormap values from prior matches
    uniqueCmap=set([x.get('colorMapName') for x in cmapMatches])
    verboseprint(uniqueCmap)

    return uniqueCmap

# POSSIBLE we may want to define a function which searches for NSEA-type bundle elements.
#   - E.G. NSEA are basically clever bundles, but may rely on parameter defs/functions that the host doesn't have
