# Copyright (C) Microsoft Corporation. All rights reserved.
# This file is distributed under the University of Illinois Open Source License. See LICENSE.TXT for details.
import argparse
import glob
import shutil
import os
import subprocess
import re

try:
    import xml.etree.cElementTree as ET
except ImportError:
    import xml.etree.ElementTree as ET

# Comment namespace to make working with ElementTree easier:
def ReadXmlString(text):
    global xmlheader
    text = text.replace('xmlns=', 'xmlns_commented=')
    xmlheader = text[:text.find('\n')+1]
    # TODO: read xml and return xml root
    return ET.fromstring(text)
def WriteXmlString(root):
    global xmlheader
    text = ET.tostring(root, encoding="utf-8")
    return xmlheader + text.replace('xmlns_commented=', 'xmlns=')
def DeepCopyElement(e):
    cpy = ET.Element(e.tag, e.attrib)
    cpy.text = e.text
    cpy.tail = e.tail
    for child in e:
        cpy.append(DeepCopyElement(child))
    return cpy

sample_names = [
    "D3D1211On12",
    "D3D12Bundles",
    "D3D12DynamicIndexing",
    "D3D12ExecuteIndirect",
    "D3D12Fullscreen",
#    "D3D12HelloWorld",
    "D3D12HelloWorld\\src\\HelloBundles",
    "D3D12HelloWorld\\src\\HelloConstBuffers",
    "D3D12HelloWorld\\src\\HelloFrameBuffering",
    "D3D12HelloWorld\\src\\HelloTexture",
    "D3D12HelloWorld\\src\\HelloTriangle",
    "D3D12HelloWorld\\src\\HelloWindow",
    "D3D12HeterogeneousMultiadapter",
        # WarpAssert in ReadPointerOperand: pInfoSrc->getName().startswith("dx.var.x").  
        # Fix tested - incorrect rendered result produced.
        # Worked around in sample shaders for now by moving to non-static locals.
    "D3D12Multithreading",      # works and is visually impressive!
    "D3D12nBodyGravity",        # expected empty cbuffer culling due to static members causes this to fail!  FIXED!
    "D3D12PipelineStateCache",  # Requires workaround for static globals
    "D3D12PredicationQueries",
    "D3D12ReservedResources",
    "D3D12Residency",       # has problems on x86 due to mem limitations. The following seems to help:
                            # change NumTextures to 1024 * 1, and add min with 1GB here:
                            # UINT64 memoryToUse = UINT64 (float(min(memoryInfo.Budget, UINT64(1 << 30))) * 0.95f);
                            # Still produces a bunch of these errors:
                            # D3D12 ERROR: ID3D12CommandAllocator::Reset: A command allocator is being reset before previous executions associated with the allocator have completed. [ EXECUTION ERROR #552: COMMAND_ALLOCATOR_SYNC]
                            # but at least it no longer crashes
    "D3D12SmallResources"]

class Sample(object):
    def __init__(self, name):
        self.name = name
        self.preBuild = []
        self.postBuild = []

samples = dict([(name, Sample(name)) for name in sample_names])

def SetSampleActions():
    # Actions called with (name, args, dxil)
    # Actions for all projects:
    for name, sample in samples.items():
        sample.postBuild.append(ActionCopyD3DBins)
        sample.postBuild.append(ActionCopySDKLayers)
        sample.postBuild.append(ActionCopyWarp12)
        sample.postBuild.append(ActionCopyCompilerBins) # Do this for all projects for now
    # TODO: limit ActionCopyCompilerBins action to ones that do run-time compilation.
    # # Runtime HLSL compilation:
    # for name in [   "D3D1211On12",
    #                 "D3D12HelloWorld\\src\\HelloTriangle",
    #                 "D3D12ExecuteIndirect",
    #             ]:
    #     samples[name].postBuild.append(ActionCopyCompilerBins)

def CopyFiles(source_path, dest_path, filenames, symbols=False):
    for filename in filenames:
        renamed = filename
        try:
            filename, renamed = filename.split(';')
            print('Copying %s from %s to %s' % (filename, source_path, dest_path))
            print('.. with new name: %s' % (renamed))
        except:
            print('Copying %s from %s to %s' % (filename, source_path, dest_path))
        try:
            shutil.copy2(os.path.join(source_path, filename), os.path.join(dest_path, renamed))
        except:
            print('Error copying "%s" from "%s" to "%s"' % (filename, source_path, dest_path))
            # sys.excepthook(*sys.exc_info())
            continue
        if symbols and (filename.endswith('.exe') or filename.endswith('.dll')):
            symbol_filename = filename[:-4] + '.pdb'
            try:
                shutil.copy2(os.path.join(source_path, symbol_filename), os.path.join(dest_path, symbol_filename))
            except:
                print('Error copying symbols "%s" from "%s" to "%s"' % (symbol_filename, source_path, dest_path))
def CopyBins(args, name, dxil, filenames, symbols=False):
    samples_path = args.samples
    config = dxil and 'DxilDebug' or 'Debug'
    CopyFiles(args.bins, os.path.join(PathToSampleSrc(samples_path, name), 'bin', args.arch, config), filenames, symbols)
def ActionCopyCompilerBins(args, name, dxil):
    if dxil:
        CopyBins(args, name, dxil, [
                'dxcompiler.dll',
                'dxil.dll',
                'd3dcompiler_dxc_bridge.dll;d3dcompiler_47.dll',   # Wrapper version that calls into dxcompiler.dll
            ], args.symbols)
def ActionCopyD3DBins(args, name, dxil):
    if dxil:
        CopyBins(args, name, dxil, [
                'd3d12.dll', 
            ], args.symbols)
def ActionCopySDKLayers(args, name, dxil):
    CopyBins(args, name, dxil, [
            'D3D11_3SDKLayers.dll',
            'D3D12SDKLayers.dll',
            'DXGIDebug.dll',
        ], args.symbols)
def ActionCopyWarp12(args, name, dxil):
    CopyBins(args, name, dxil, [
            'd3d12warp.dll',
        ], args.symbols)
def MakeD3D12WarpCopy(bin_path):
    # Copy d3d10warp.dll to d3d12warp.dll
    shutil.copy2(os.path.join(bin_path, 'd3d10warp.dll'), os.path.join(bin_path, 'd3d12warp.dll'))

def PathSplitAll(p):
    s = filter(None, os.path.split(p))
    if len(s) > 1:
        return PathSplitAll(s[0]) + (s[1],)
    else:
        return (s[0],)

def GetBinPath(args, name):
    return os.path.join(args.bins, name)

def GetProjectBinFilePath(args, samples_path, sample_name, file_name):
    src = PathToSampleSrc(samples_path, sample_name)
    return os.path.join(src, "bin", args.arch, file_name)

def ListRuntimeCompilePaths(args):
    return [os.path.join(args.bins, name) for name in [
        'fxc.exe',
        'dxc.exe',
        'dxcompiler.dll',
        'dxil.dll',
        'd3dcompiler_47.dll',
        'd3d12.dll',
        'D3D11_3SDKLayers.dll',
        'D3D12SDKLayers.dll',
        'DXGIDebug.dll',
        'd3d12warp.dll',
    ]]

def CheckEnvironment(args):
    if not args.bins:
        print("The -bins argument is needed to populate tool binaries.")
        exit(1)
    if not os.path.exists(args.bins):
        print("The -bins argument '" + args.bins + "' does not exist.")
        exit(1)
    for fn in ListRuntimeCompilePaths(args):
        if not os.path.exists(fn):
            print("Expected file '" + fn + "' not found.")
            exit(1)
    if os.path.getmtime(GetBinPath(args, 'fxc.exe')) != os.path.getmtime(GetBinPath(args, 'dxc.exe')):
        print("fxc.exe should be a copy of dxc.exe.")
        print("Please copy " + GetBinPath(args, 'dxc.exe') + " " + GetBinPath(args, 'fxc.exe'))
        exit(1)
    try:
        msbuild_version = subprocess.check_output(["msbuild", "-nologo", "-ver"])
        print("msbuild version: " + msbuild_version)
    except Exception as E:
        print("Unable to get the version from msbuild: " + str(E))
        print("This command should be run from a Developer Command Prompt")
        exit(1)

def SampleIsNested(name):
    return "\\src\\" in name

def PathToSampleSrc(basePath, sampleName):
    if SampleIsNested(sampleName):
        return os.path.join(basePath, "Samples", "Desktop", sampleName)
    return os.path.join(basePath, "Samples", "Desktop", sampleName, "src")

reConfig = r'(Debug|Release|DxilDebug|DxilRelease)\|(Win32|x64)'

def AddProjectConfigs(root, args):
    rxConfig = re.compile(reConfig)
    changed = False
    for e in root.findall('.//WindowsTargetPlatformVersion'):
        if e.text == '10.0.10240.0':
            e.text = '10.0.10586.0'
            changed = True
    # Override fxc path for Dxil configs:
    for config in ['DxilDebug', 'DxilRelease']:
        for arch in ['Win32', 'x64']:
            if not root.find('''./PropertyGroup[@Condition="'$(Configuration)|$(Platform)'=='%s|%s'"]/FXCToolPath''' % (config, arch)):
                e = ET.Element('PropertyGroup', {'Condition': "'$(Configuration)|$(Platform)'=='%s|%s'" % (config, arch)})
                e.text = '\n    '
                e.tail = '\n  '
                root.insert(0, e)
                e = ET.SubElement(e, 'FXCToolPath')
                e.text = '$(DXC_BIN_PATH)' # args.bins
                e.tail = '\n  '
    # Extend ProjectConfiguration for Win32 and Dxil configs
    ig = root.find('./ItemGroup[@Label="ProjectConfigurations"]') or []
    debug_config = release_config = None    # ProjectConfiguration
    configs = {}
    for e in ig:
        try:
            m = rxConfig.match(e.attrib['Include'])
            if m:
                key = m.groups()
                configs[key] = e
                if m.group(1) == 'Debug':
                    debug_config = key, e
                elif m.group(1) == 'Release':
                    release_config = key, e
        except:
            continue
    parents = root.findall('''.//*[@Condition="'$(Configuration)|$(Platform)'=='%s|%s'"]/..''' % ('Debug', 'x64'))
    if not configs or not debug_config or not release_config:
        print('No ProjectConfigurations found')
        return False
    for arch in ['Win32', 'x64']:
        for config in ['Debug', 'DxilDebug', 'Release', 'DxilRelease']:
            if config in ('Debug', 'DxilDebug'):
                t_config = 'Debug', 'x64'
            else:
                t_config = 'Release', 'x64'
            config_condition = "'$(Configuration)|$(Platform)'=='%s|%s'" % t_config
            if not configs.get((config, arch), None):
                changed = True
                if config in ('Debug', 'DxilDebug'):
                    e = DeepCopyElement(debug_config[1])
                else:
                    e = DeepCopyElement(release_config[1])
                e.set('Include', '%s|%s' % (config, arch))
                e.find('./Configuration').text = config
                e.find('./Platform').text = arch
                ig.append(e)
                for parent in reversed(parents):
                    for n, e in reversed(list(enumerate(parent))):
                        try:
                            cond = e.attrib['Condition']
                        except KeyError:
                            continue
                        if cond == config_condition:
                            e = DeepCopyElement(e)
                            # Override DisableOptimizations for DxilDebug since this flag is problematic right now
                            if e.tag == 'ItemDefinitionGroup' and config == 'DxilDebug':
                                FxCompile = e.find('./FxCompile') or ET.SubElement(e, 'FxCompile')
                                DisableOptimizations = FxCompile.find('./DisableOptimizations') or ET.SubElement(FxCompile, 'DisableOptimizations')
                                DisableOptimizations.text = 'false'
                            e.attrib['Condition'] = "'$(Configuration)|$(Platform)'=='%s|%s'" % (config, arch)
                            parent.insert(n+1, e)
    return changed

def AddSlnConfigs(sln_text):
    # sln: GlobalSection(SolutionConfigurationPlatforms)
    rxSlnConfig = re.compile(r'^\s+%s = \1\|\2$' % reConfig)
    # sln: GlobalSection(ProjectConfigurationPlatforms)
    rxActiveCfg = re.compile(r'^\s+\{[0-9A-Z-]+\}\.%s\.ActiveCfg = \1\|\2$' % reConfig)
    rxBuild = re.compile(r'^\s+\{[0-9A-Z-]+\}\.%s\.Build\.0 = \1\|\2$' % reConfig)
    sln_changed = []
    line_set = set(sln_text.splitlines())
    def add_line(lst, line):
        "Prevent duplicates from being added"
        if line not in line_set:
            lst.append(line)
    for line in sln_text.splitlines():
        if line == 'VisualStudioVersion = 14.0.23107.0':
            sln_changed.append('VisualStudioVersion = 14.0.25123.0')
            continue
        m = rxSlnConfig.match(line)
        if not m:
            m = rxActiveCfg.match(line)
        if not m:
            m = rxBuild.match(line)
        if m:
            sln_changed.append(line)
            config = m.group(1)
            if config in ('Debug', 'Release') and m.group(2) == 'x64':
                add_line(sln_changed, line.replace('x64', 'Win32'))
                add_line(sln_changed, line.replace(config, 'Dxil' + config))
                add_line(sln_changed, line.replace(config, 'Dxil' + config).replace('x64', 'Win32'))
            continue
        sln_changed.append(line)
    return '\n'.join(sln_changed)

def PatchProjects(args):
    for name in sample_names + ['D3D12HelloWorld']:
        sample_path = PathToSampleSrc(args.samples, name)
        print("Patching " + name + " in " + sample_path)
        for proj_path in glob.glob(os.path.join(sample_path, "*.vcxproj")):
            # Consider looking for msbuild and other tool paths in registry, etg:
            # reg query HKLM\Software\Microsoft\MSBuild\ToolsVersions\14.0
            with open (proj_path, "r") as proj_file:
                proj_text = proj_file.read()
            root = ReadXmlString(proj_text)
            if AddProjectConfigs(root, args):
                changed_text = WriteXmlString(root)
                print("Patching the Windows SDK version in " + proj_path)
                with open (proj_path, "w") as proj_file:
                    proj_file.write(changed_text)

        # Extend project configs in solution file
        for sln_path in glob.glob(os.path.join(sample_path, "*.sln")):
            with open (sln_path, "r") as sln_file:
                sln_text = sln_file.read()
            changed_text = AddSlnConfigs(sln_text)
            if changed_text != sln_text:
                print("Adding additional configurations to " + sln_path)
                with open (sln_path, "w") as sln_file:
                    sln_file.write(changed_text)

def BuildSample(samples_path, name, x86, dxil):
    sample_path = PathToSampleSrc(samples_path, name)
    if not SampleIsNested(name):
        print("Building " + name + " in " + sample_path)
        Platform = x86 and 'Win32' or 'x64'
        Configuration = dxil and 'DxilDebug' or 'Debug'
        subprocess.check_call(["msbuild", "-nologo", 
            '/p:Configuration=%s;Platform=%s' % (Configuration, Platform), 
            '/t:Rebuild'], 
            cwd=sample_path)

def BuildSamples(args, dxil):
    samples_path = args.samples
    rxSample = args.sample and re.compile(args.sample, re.I)
    buildHelloWorld = False
    os.environ['DXC_BIN_PATH'] = args.bins
    for sample_name in sample_names:
        if rxSample and not rxSample.match(sample_name):
            continue
        if SampleIsNested(sample_name):
            buildHelloWorld = True
            continue
        BuildSample(samples_path, sample_name, args.x86, dxil)
    if buildHelloWorld:
        # HelloWorld containts sub-samples that must be built from the solution file.
        BuildSample(samples_path, "D3D12HelloWorld", args.x86, dxil)

def PatchSample(args, name, dxil, afterBuild):
    if args.sample and not re.match(args.sample, name, re.I):
        return
    try:
        sample = samples[name]
    except:
        print("Error: selected sample missing from sample map '" + name + "'.")
        return
    if afterBuild:  actions = sample.postBuild
    else:           actions = sample.preBuild
    for action in actions:
        action(args, name, dxil)

def PatchSamplesAfterBuild(args, dxil):
    for sample_name in sample_names:
        PatchSample(args, sample_name, dxil, True)

def PatchSamplesBeforeBuild(args, dxil):
    for sample_name in sample_names:
        PatchSample(args, sample_name, dxil, False)

def RunSampleTests(args):
    CheckEnvironment(args)
    print("Building Debug config ...")
    BuildSamples(args, False)
    
    print("Building DxilDebug config ...")
    BuildSamples(args, True)

    print("Applying patch to post-build binaries to enable dxc ...")
    PatchSamplesAfterBuild(args, False)
    PatchSamplesAfterBuild(args, True)

    print("TODO - run Debug config vs. DxilDebug config and verify results are the same")

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Run D3D sample tests...")
    parser.add_argument("-x86", action="store_true", help="add x86 targets")
    parser.add_argument("-samples", help="path to root of D3D12 samples")
    parser.add_argument("-bins", help="path to dxcompiler.dll and related binaries")
    parser.add_argument("-sample", help="choose a single sample to build/test (* wildcard supported)")
    parser.add_argument("-postbuild", action="store_true", help="only perform post-build operations")
    parser.add_argument("-patch", action="store_true", help="patch projects")
    parser.add_argument("-symbols", action="store_true", help="try to copy symbols for various dependencies")
    args = parser.parse_args()

    SetSampleActions()

    if args.x86:
        args.arch = "Win32"
    else:
        args.arch = "x64"

    if not args.samples:
        print("The -samples option must be used to indicate the root of D3D12 Samples.")
        print("Samples are available at this URL.")
        print("https://github.com/Microsoft/DirectX-Graphics-Samples")
        exit(1)

    if args.sample:
        print('Applying sample filter: %s' % args.sample)
        args.sample = re.escape(args.sample).replace('\\*', '.*')
        rxSample = re.compile(args.sample, re.I)
        for name in samples:
            if rxSample.match(name):
                print('  %s' % name)

    if args.postbuild:
        print("Applying patch to post-build binaries to enable dxc ...")
        PatchSamplesAfterBuild(args, False)
        PatchSamplesAfterBuild(args, True)
    elif args.patch:
        print("Patching projects ...")
        PatchProjects(args)
    else:
        RunSampleTests(args)

