File: src\CreateWixBuildWixpack.cs
Web Access
Project: Microsoft.DotNet.Build.Tasks.Installers.csproj (Microsoft.DotNet.Build.Tasks.Installers)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;

namespace Microsoft.DotNet.Build.Tasks.Installers;

/*
 * This task creates a Wixpack package from the provided source files and configuration.
 * It processes the source files, copies necessary content files to a working directory,
 * updates paths and variables in source-files, and generates a command line file
 * for building the Wixpack. Content files get copied to a subfolder named after the File@Id
 * or similar unique value, based on the content element type.
 * We are including extensions in wixpack, which allows us to skip restoring these packages
 * and discover extension binaries during signing/repacking.
 * Finally, this task creates a zip package containing all the necessary files.
 * The task supports various configurations such as cultures, define constants, extensions,
 * include search paths, installer platform, output folder, and more.
 */
[MSBuildMultiThreadableTask]
public class CreateWixBuildWixpack : Task, IMultiThreadableTask
{
    /// <summary>Injected by MSBuild so paths resolve against the project directory in multithreaded builds.</summary>
    public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback;

    public string AdditionalOptions { get; set; }

    public ITaskItem BindTrackingFile { get; set; }

    public ITaskItem[] BindPaths { get; set; }

    public string[] Cultures { get; set; }

    public string[] DefineConstants { get; set; }

    public ITaskItem[] Extensions { get; set; }

    public string[] IncludeSearchPaths { get; set; }

    public string InstallerPlatform { get; set; }

    [Required]
    public string InstallerFile { get; set; }

    [Required]
    public ITaskItem IntermediateDirectory { get; set; }

    public ITaskItem[] LocalizationFiles { get; set; }

    /// <summary>
    /// path of wixpackage file
    /// </summary>
    [Output]
    public string OutputFile { get; set; }

    /// <summary>
    /// folder to place wixpackage output file
    /// </summary>
    [Required]
    public string OutputFolder { get; set; }

    public string OutputType { get; set; }

    public ITaskItem PdbFile { get; set; }

    public string PdbType { get; set; }

    [Required]
    public ITaskItem[] SourceFiles { get; set; }

    public string[] SuppressSpecificWarnings { get; set; }

    [Required]
    public string WixpackWorkingDir { get; set; }

    private Dictionary<string, string> _defineConstantsDictionary;
    private Dictionary<string, string> _defineVariablesDictionary;
    private Dictionary<string, string> _systemVariablesDictionary;
    private string _wixprojDir;
    private string _installerFilename;

    private const string _packageExtension = ".wixpack.zip";

    public override bool Execute()
    {
        try
        {
            _defineConstantsDictionary = GetDefineConstantsDictionary();
            _systemVariablesDictionary = GetSystemVariablesDictionary();

            if (string.IsNullOrWhiteSpace(WixpackWorkingDir))
            {
                // MSBuildTask0002: the temp root is only used as the parent of a freshly generated unique
                // directory/file name, so it is never shared between concurrently running tasks.
#pragma warning disable MSBuildTask0002
                WixpackWorkingDir = Path.Combine(Path.GetTempPath(), "WixpackTemp", Guid.NewGuid().ToString().Split('-')[0]);
#pragma warning restore MSBuildTask0002
            }

            _installerFilename = Path.GetFileName(InstallerFile);

            AbsolutePath wixpackWorkingDir = TaskEnvironment.GetAbsolutePath(WixpackWorkingDir);
            if (Directory.Exists(wixpackWorkingDir))
            {
                Directory.Delete(wixpackWorkingDir, true);
            }
            Directory.CreateDirectory(wixpackWorkingDir);

            if (_defineConstantsDictionary.TryGetValue("ProjectDir", out _wixprojDir))
            {
                // Copy wixproj file - fail if ProjectPath is not defined
                if (_defineConstantsDictionary.TryGetValue("ProjectPath", out var projectPath))
                {
                    AbsolutePath projectPathAbsolute = TaskEnvironment.GetAbsolutePath(projectPath);
                    AbsolutePath destPathAbsolute = TaskEnvironment.GetAbsolutePath(Path.Combine(wixpackWorkingDir, Path.GetFileName(projectPath)));
                    File.Copy(projectPathAbsolute, destPathAbsolute, overwrite: true);
                }
                else
                {
                    throw new InvalidOperationException("ProjectPath not defined in DefineConstants. Task cannot proceed.");
                }
            }
            else
            {
                _wixprojDir = string.Empty;
            }

            CopyIncludeSearchPathsContents(wixpackWorkingDir);
            ProcessIncludeFilesInSearchPaths(wixpackWorkingDir);
            CopySourceFilesAndContent(wixpackWorkingDir);
            CopyExtensions();
            CopyBindPathContents();
            CopyLocalizationFiles();
            UpdatePaths();
            GenerateWixBuildCommandLineFile(wixpackWorkingDir);
            CreateWixpackPackage(wixpackWorkingDir);
        }
        catch (Exception e)
        {
            Log.LogErrorFromException(e, true);
        }

        return !Log.HasLoggedErrors;
    }

    private void CreateWixpackPackage(AbsolutePath wixpackWorkingDir)
    {
        OutputFile = Path.Combine(OutputFolder, $"{_installerFilename}{_packageExtension}");
        AbsolutePath outputFile = TaskEnvironment.GetAbsolutePath(OutputFile);
        if (File.Exists(outputFile))
        {
            File.Delete(outputFile);
        }

        AbsolutePath outputFolder = TaskEnvironment.GetAbsolutePath(OutputFolder);
        if (!Directory.Exists(outputFolder))
        {
            Directory.CreateDirectory(outputFolder);
        }

        ZipFile.CreateFromDirectory(wixpackWorkingDir, outputFile);
    }

    private void CopyIncludeSearchPathsContents(AbsolutePath wixpackWorkingDir)
    {
        if (IncludeSearchPaths == null || IncludeSearchPaths.Length == 0)
        {
            return;
        }

        for (int i = 0; i < IncludeSearchPaths.Length; i++)
        {
            // If not rooted, resolve relative to _wixprojDir
            var fullSourceDir = GetAbsoluteSourcePath(IncludeSearchPaths[i]);
            AbsolutePath fullSourceDirPath = TaskEnvironment.GetAbsolutePath(fullSourceDir);
            if (!Directory.Exists(fullSourceDirPath))
            {
                Log.LogWarning($"IncludeSearchPath directory not found: {fullSourceDir}");
                continue;
            }

            // Use a random directory name for the destination
            var randomDirName = Path.GetRandomFileName();
            IncludeSearchPaths[i] = randomDirName;

            AbsolutePath destinationDir = TaskEnvironment.GetAbsolutePath(Path.Combine(wixpackWorkingDir, randomDirName));
            CopyDirectoryRecursive(fullSourceDirPath, destinationDir);
        }
    }

    private void ProcessIncludeFilesInSearchPaths(AbsolutePath wixpackWorkingDir)
    {
        _defineVariablesDictionary = new Dictionary<string, string>(System.StringComparer.OrdinalIgnoreCase);
        foreach (var includeFile in Directory.GetFiles(wixpackWorkingDir, "*.wxi", SearchOption.AllDirectories))
        {
            AbsolutePath includeFilePath = TaskEnvironment.GetAbsolutePath(includeFile);
            ProcessIncludeFile(includeFilePath);
        }
    }

    private void ProcessIncludeFile(AbsolutePath includeFilePath)
    {
        // Copy includeFile to %temp% folder
        // We want to keep original files in wixpack, and only preprocess
        // them for wixpack creation. This ensures that repacking process would not be
        // affected by some unintentional change, or a bug in preprocessor.
        // MSBuildTask0002: the temp root is only used as the parent of a freshly generated unique
        // directory/file name, so it is never shared between concurrently running tasks.
#pragma warning disable MSBuildTask0002
        var tempFilePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
#pragma warning restore MSBuildTask0002
        AbsolutePath tempFileAbsolutePath = TaskEnvironment.GetAbsolutePath(tempFilePath);
        File.Copy(includeFilePath, tempFileAbsolutePath, overwrite: true);

        // We're processing a Wix include file, which contains preprocessor
        // and other custom elements that are processed as strings using Regex.
        // It can also contain XML comments that we need to remove, so we don't ingest
        // variables from elements that are commented out.
        RemoveAllXmlComments(tempFileAbsolutePath);
        PreprocessWixSourceFile(tempFileAbsolutePath);
        IngestDefineVariablesFromWixFile(tempFileAbsolutePath);

        File.Delete(tempFileAbsolutePath);
    }

    private void IngestDefineVariablesFromWixFile(AbsolutePath filePath)
    {
        try
        {
            IngestDefineVariablesFromString(XDocument.Load(filePath).ToString());
        }
        catch (Exception ex)
        {
            Log.LogError($"Error ingesting variables from include file {filePath.OriginalValue}: {ex.Message}");
        }
    }

    private void IngestDefineVariablesFromString(string content)
    {
        // We use regular expressions to process wix preprocessor defines
        var regex = new Regex(@"<\?define\s+(\w+)\s*=\s*""([^""]*)""\s*\?>");

        foreach (Match match in regex.Matches(content))
        {
            if (match.Groups.Count == 3)
            {
                _defineVariablesDictionary[match.Groups[1].Value] = ResolvePath(match.Groups[2].Value);
            }
        }
    }

    private static void RemoveAllXmlComments(AbsolutePath filePath)
    {
        XDocument xmlDocument = XDocument.Load(filePath);
        xmlDocument.DescendantNodes()
                   .OfType<XComment>()
                   .ToList()
                   .ForEach(comment => comment.Remove());
        xmlDocument.Save(filePath);
    }

    private void UpdatePaths()
    {
        // Update ProjectDir to just '.'
        if (_defineConstantsDictionary.ContainsKey("ProjectDir"))
        {
            _defineConstantsDictionary["ProjectDir"] = ".";
        }

        // Update ProjectPath to just the project file name
        if (_defineConstantsDictionary.TryGetValue("ProjectPath", out var projectPath))
        {
            _defineConstantsDictionary["ProjectPath"] = Path.GetFileName(projectPath);
        }

        // Update OutDir to just '.''
        if (_defineConstantsDictionary.ContainsKey("OutDir"))
        {
            _defineConstantsDictionary["OutDir"] = "%outputfolder%";
        }

        // Update TargetDir to just '.''
        if (_defineConstantsDictionary.ContainsKey("TargetDir"))
        {
            _defineConstantsDictionary["TargetDir"] = "%outputfolder%";
        }

        // Update TargetPath to %outputfolder%\<target file name>
        if (_defineConstantsDictionary.TryGetValue("TargetPath", out var targetPath))
        {
            _defineConstantsDictionary["TargetPath"] = Path.Combine("%outputfolder%", Path.GetFileName(targetPath));
        }

        // Update InstallerFile to %outputfolder%\<installer filename>
        InstallerFile = Path.Combine("%outputfolder%", Path.GetFileName(InstallerFile));

        // Update IntermediateDirectory to %outputfolder%
        IntermediateDirectory.ItemSpec = "%outputfolder%";

        // Update PdbFile to %outputfolder%\<pdb file name>
        if (PdbFile != null && !string.IsNullOrEmpty(PdbFile.ItemSpec))
        {
            PdbFile.ItemSpec = Path.Combine("%outputfolder%", Path.GetFileName(PdbFile.ItemSpec));
        }

        // Update BindTrackingFile to %outputfolder%\<bind tracking file name>
        if (BindTrackingFile != null && !string.IsNullOrEmpty(BindTrackingFile.ItemSpec))
        {
            BindTrackingFile.ItemSpec = Path.Combine("%outputfolder%", Path.GetFileName(BindTrackingFile.ItemSpec));
        }
    }

    private void GenerateWixBuildCommandLineFile(AbsolutePath wixpackWorkingDir)
    {
        var commandLineArgs = new List<string>();

        // Add InstallerPlatform if specified
        if (!string.IsNullOrEmpty(InstallerPlatform))
        {
            commandLineArgs.Add($"-platform {InstallerPlatform}");
        }

        commandLineArgs.Add($"-out {InstallerFile}");

        // Add OutputType if specified
        if (!string.IsNullOrEmpty(OutputType))
        {
            commandLineArgs.Add($"-outputType {OutputType}");
        }

        // Add PdbFile if specified
        if (PdbFile != null && !string.IsNullOrEmpty(PdbFile.ItemSpec))
        {
            commandLineArgs.Add($"-pdb {PdbFile.ItemSpec}");
        }

        // Add PdbType if specified
        if (!string.IsNullOrEmpty(PdbType))
        {
            commandLineArgs.Add($"-pdbType {PdbType}");
        }

        // Add each culture from Cultures array
        if (Cultures != null && Cultures.Length > 0)
        {
            foreach (var culture in Cultures)
            {
                commandLineArgs.Add($"-culture {culture}");
            }
        }

        // Add each Warning from SuppressSpecificWarnings array
        if (SuppressSpecificWarnings != null && SuppressSpecificWarnings.Length > 0)
        {
            foreach (var warning in SuppressSpecificWarnings)
            {
                commandLineArgs.Add($"-sw{warning}");
            }
        }

        // Add all define constants from dictionary
        if (_defineConstantsDictionary != null && _defineConstantsDictionary.Count > 0)
        {
            foreach (var kvp in _defineConstantsDictionary)
            {
                // Escape strings only if there is a space in the value
                string kv = $"{kvp.Key}={kvp.Value}";
                commandLineArgs.Add($"-d {(kv.Contains(' ') ? $"\"{kv}\"" : kv)}");
            }
        }

        // Add IncludeSearchPaths
        if (IncludeSearchPaths != null && IncludeSearchPaths.Length > 0)
        {
            foreach (var includePath in IncludeSearchPaths)
            {
                commandLineArgs.Add($"-I {includePath}");
            }
        }

        // Add Extensions
        if (Extensions != null)
        {
            foreach (var extension in Extensions)
            {
                commandLineArgs.Add($"-ext {extension.ItemSpec}");
            }
        }

        // Add LocalizationFiles
        if (LocalizationFiles != null)
        {
            foreach (var localizationFile in LocalizationFiles)
            {
                commandLineArgs.Add($"-loc {localizationFile.ItemSpec}");
            }
        }

        // Add BindPaths
        if (BindPaths != null && BindPaths.Length > 0)
        {
            foreach (var bindPath in BindPaths)
            {
                string bindName = bindPath.GetMetadata("BindName");
                if (!string.IsNullOrEmpty(bindName))
                {
                    // Wix build specifies both arguments - matching it here.
                    commandLineArgs.Add($"-bindPath {bindPath.ItemSpec}");
                    commandLineArgs.Add($"-bindPath {bindName}={bindPath.ItemSpec}");
                }
            }
        }

        // Add IntermediateDirectory
        commandLineArgs.Add($"-intermediatefolder {IntermediateDirectory.ItemSpec}");

        // Add BindTrackingFile if specified
        if (BindTrackingFile != null && !string.IsNullOrEmpty(BindTrackingFile.ItemSpec))
        {
            commandLineArgs.Add($"-trackingfile {BindTrackingFile.ItemSpec}");
        }

        // Add AdditionalOptions if specified
        if (!string.IsNullOrEmpty(AdditionalOptions))
        {
            commandLineArgs.Add(AdditionalOptions);
        }

        commandLineArgs.Add($"-nologo");
        commandLineArgs.Add($"-wx");

        // Add SourceFiles
        if (SourceFiles != null && SourceFiles.Length > 0)
        {
            foreach (var sourceFile in SourceFiles)
            {
                commandLineArgs.Add($"{Path.GetFileName(sourceFile.ItemSpec)}");
            }
        }

        // The command lines can be quite long, and cmd would reject them. Wix does support
        // response files, so create a response file (create.rsp) to package alongside.
        AbsolutePath responseFilePath = TaskEnvironment.GetAbsolutePath(Path.Combine(wixpackWorkingDir, "create.rsp"));
        File.WriteAllText(responseFilePath, string.Join(System.Environment.NewLine, commandLineArgs));

        string commandLine = "wix.exe build @create.rsp";

        StringBuilder createCmdFileContents = new();
        createCmdFileContents.AppendLine("@echo off");
        createCmdFileContents.AppendLine("set outputfolder=%1");
        createCmdFileContents.AppendLine("if \"%outputfolder%\" NEQ \"\" (");
        createCmdFileContents.AppendLine("  if \"%outputfolder:~-1%\" NEQ \"\\\" ( ");
        createCmdFileContents.AppendLine("    set outputfolder=%outputfolder%\\");
        createCmdFileContents.AppendLine("  )");
        createCmdFileContents.AppendLine(")");
        createCmdFileContents.AppendLine("REM Wix build command");
        createCmdFileContents.AppendLine(commandLine);
        AbsolutePath commandFilePath = TaskEnvironment.GetAbsolutePath(Path.Combine(wixpackWorkingDir, "create.cmd"));
        File.WriteAllText(commandFilePath, createCmdFileContents.ToString());
    }

    /// <summary>
    /// Gets a Dictionary from DefineConstants string array (format: key=value)
    /// </summary>
    private Dictionary<string, string> GetDefineConstantsDictionary()
    {
        var dict = new Dictionary<string, string>(System.StringComparer.OrdinalIgnoreCase);
        if (DefineConstants == null)
        {
            return dict;
        }

        foreach (var entry in DefineConstants)
        {
            if (string.IsNullOrWhiteSpace(entry))
            {
                continue;
            }

            var idx = entry.IndexOf('=');
            if (idx > -1)
            {
                var key = entry.Substring(0, idx);
                var value = entry.Substring(idx + 1);

                if (!string.IsNullOrEmpty(key))
                {
                    dict[key] = value;
                }
            }
        }

        return dict;
    }

    private Dictionary<string, string> GetSystemVariablesDictionary()
    {
        var dict = new Dictionary<string, string>(System.StringComparer.OrdinalIgnoreCase);

        // Add support for known used system variables
        if (_defineConstantsDictionary.TryGetValue("InstallerPlatform", out var installerPlatform))
        {
            dict.Add("BUILDARCH", installerPlatform);
        }

        return dict;
    }

    /// <summary>
    /// For each item in SourceFiles, reads the XML, finds all File elements, gets File@Id and File@Source values.
    /// If File@Source contains $(<value>), replaces it with the value from _defineConstantsDictionary.
    /// Creates a subfolder in WixpackWorkingDir with the name equal to File@Id value.
    /// </summary>
    private void CopySourceFilesAndContent(AbsolutePath wixpackWorkingDir)
    {
        if (SourceFiles == null || _defineConstantsDictionary == null || string.IsNullOrEmpty(WixpackWorkingDir))
        {
            throw new InvalidOperationException("Task not initialized. Run Execute() first.");
        }

        foreach (var sourceFile in SourceFiles)
        {
            var xmlPath = GetAbsoluteSourcePath(sourceFile.ItemSpec);
            AbsolutePath xmlFilePath = TaskEnvironment.GetAbsolutePath(xmlPath);
            if (!File.Exists(xmlFilePath))
            {
                Log.LogError($"Source file not found: {sourceFile.ItemSpec}");
                continue;
            }

            // Copy the sourceFile to WixpackWorkingDir
            var copiedXmlPath = Path.Combine(WixpackWorkingDir, Path.GetFileName(xmlPath));
            AbsolutePath copiedXmlFilePath = TaskEnvironment.GetAbsolutePath(copiedXmlPath);
            File.Copy(xmlFilePath, copiedXmlFilePath, overwrite: true);
            string sourceFileFolder = Path.GetDirectoryName(xmlPath);

            // First preprocess the source file to remove non-applicable include files.
            // We defer ingestion of variables, until all variables from include files
            // were ingested, as they may reference each other.
            RemoveAllXmlComments(copiedXmlFilePath);
            PreprocessWixSourceFile(copiedXmlFilePath);

            // Ingest variables after file preprocessing
            ProcessAllReferencedIncludeFiles(copiedXmlFilePath, sourceFileFolder, wixpackWorkingDir);
            IngestDefineVariablesFromWixFile(copiedXmlFilePath);

            try
            {
                var doc = XDocument.Load(copiedXmlFilePath);

                var contentElements = new (string, string, string[])[]
                {
                    ("File", "Id", ["Source"]),
                    ("Binary", "Id", ["SourceFile"]),
                    ("MsiPackage", "Id", ["SourceFile"]),
                    ("ExePackage", "Id", ["SourceFile"]),
                    ("Payload", "Id", ["SourceFile"]),
                    ("WixStandardBootstrapperApplication", "Id", ["LicenseFile", "LocalizationFile", "ThemeFile", "LogoFile"]),
                    ("WixVariable", "Id", ["Value"]),
                    ("Icon", "Id", ["SourceFile"])
                };

                foreach (var (elementName, idAttr, sourceAttrArray) in contentElements)
                {
                    var elements = doc.Descendants().Where(e => e.Name.LocalName == elementName);
                    foreach (var element in elements)
                    {
                        foreach (var sourceAttr in sourceAttrArray)
                        {
                            var source = element.Attribute(sourceAttr)?.Value;

                            if (string.IsNullOrEmpty(source))
                            {
                                continue;
                            }

                            source = ResolvePath(source);

                            // If there are any unprocessed tokens, process all matching file patterns
                            // We only support one unprocessed token, specified as immediate file parent
                            // [<path>\\]{pattern}\\<filename>
                            if (source.Contains("$("))
                            {
                                int startIdx = source.IndexOf("$(");
                                if (startIdx != source.LastIndexOf("$("))
                                {
                                    Log.LogError($"Multiple unprocessed tokens found in source: {source}.");
                                    continue;
                                }

                                string pattern = source.Substring(startIdx, source.IndexOf(')', startIdx + 2) - startIdx + 1);

                                source = GetAbsoluteSourcePath(source, sourceFileFolder);

                                var parts = source.Split([$"\\{pattern}\\"], StringSplitOptions.None);
                                if (parts.Length < 2)
                                {
                                    Log.LogError($"Unprocessed token: {pattern} in {xmlPath}");
                                    continue;
                                }

                                if (parts.Length > 2 || parts[1].Contains('\\'))
                                {
                                    Log.LogError($"Unsupported source format: {source}");
                                    continue;
                                }

                                // Enumerate directories in parts[0]
                                AbsolutePath sourceDir = TaskEnvironment.GetAbsolutePath(parts[0]);
                                var dirs = Directory.GetDirectories(sourceDir, "*", SearchOption.TopDirectoryOnly);
                                foreach (var dir in dirs)
                                {
                                    var filePath = Path.Combine(dir, Path.GetFileName(source));
                                    CopySourceFile(Path.GetFileName(dir), filePath);
                                }

                                element.SetAttributeValue(sourceAttr, $"{pattern}\\{parts[1]}");
                            }
                            else
                            {
                                // Resolved source is a single file, copy it to a subfolder
                                var id = element.Attribute(idAttr)?.Value;
                                if (string.IsNullOrEmpty(id))
                                {
                                    id = Path.GetFileName(source);
                                }

                                CopySourceFile(id, source, sourceFileFolder);

                                // Update the original attribute to "<id>\\<filename>"
                                var newSourceValue = $"{id}\\{Path.GetFileName(source)}";
                                element.SetAttributeValue(sourceAttr, newSourceValue);
                            }
                        }
                    }
                }

                doc.Save(copiedXmlFilePath);
            }
            catch (Exception ex)
            {
                Log.LogError($"Error processing {copiedXmlPath}: {ex.Message}");
            }
        }
    }

    /// <summary>
    /// This method will process all include files, from this source file.
    /// It will update the source file with new paths for included files,
    /// in the wixpack working directory.
    /// </summary>
    private void ProcessAllReferencedIncludeFiles(AbsolutePath filePath, string relativeRoot, AbsolutePath wixpackWorkingDir)
    {
        string content = File.ReadAllText(filePath);

        // Regex to match <?include value ?>
        var regex = new Regex(@"<\?include\s+([^\s\?>]+)\s*\?>", RegexOptions.IgnoreCase);
        foreach (Match match in regex.Matches(content))
        {
            if (match.Groups.Count > 1)
            {
                string filename = match.Groups[1].Value.Trim('\"');
                string includeFilePath = GetAbsoluteSourcePath(ResolvePath(filename), relativeRoot);
                AbsolutePath includeFileAbsolutePath = TaskEnvironment.GetAbsolutePath(includeFilePath);
                if (File.Exists(includeFileAbsolutePath))
                {
                    // Copy the include file, update the source file with new path
                    // and ingest the variables.
                    string id = Path.GetFileName(includeFilePath);
                    AbsolutePath copiedIncludeFilePath = CopySourceFile(id, includeFilePath, relativeRoot);
                    ProcessIncludeFile(copiedIncludeFilePath);
                    content = content.Replace(filename, $"{id}\\{id}");
                }
                else
                {
                    // Include file could be in IncludeSearchPaths and already copied and processed.
                    bool foundInSearchPath = false;
                    if (IncludeSearchPaths != null)
                    {
                        foreach (var searchPath in IncludeSearchPaths)
                        {
                            var potentialPath = Path.Combine(wixpackWorkingDir, searchPath, Path.GetFileName(includeFilePath));
                            AbsolutePath potentialFilePath = TaskEnvironment.GetAbsolutePath(potentialPath);
                            if (File.Exists(potentialFilePath))
                            {
                                foundInSearchPath = true;
                                break;
                            }
                        }
                    }

                    if (!foundInSearchPath)
                    {
                        Log.LogError($"Included file not found: {includeFilePath}");
                    }
                }
            }
        }

        File.WriteAllText(filePath, content);
    }

    private string ResolvePath(string path)
    {
        // Replace $(<value>) with value from _defineConstantsDictionary
        int startIdx = path.IndexOf("$(");
        while (startIdx != -1)
        {
            int endIdx = path.IndexOf(')', startIdx + 2);
            if (endIdx == -1)
            {
                Log.LogError($"Unmatched $() in path: {path}");
                break;
            }

            var varName = path.Substring(startIdx + 2, endIdx - (startIdx + 2));
            if (varName.StartsWith("sys."))
            {
                if (_systemVariablesDictionary.TryGetValue(varName.Substring(4), out var varValue))
                {
                    path = path.Substring(0, startIdx) + varValue + path.Substring(endIdx + 1);
                }
                else
                {
                    // We support tokenized paths
                    break;
                }
            }
            else
            {
                if (varName.StartsWith("var."))
                {
                    varName = varName.Substring(4);
                }

                if (_defineConstantsDictionary.TryGetValue(varName, out var varValue) ||
                    _defineVariablesDictionary.TryGetValue(varName, out varValue))
                {
                    path = path.Substring(0, startIdx) + varValue + path.Substring(endIdx + 1);
                }
                else
                {
                    // We support tokenized paths
                    break;
                }
            }

            startIdx = path.IndexOf("$(");
        }

        return path;
    }

    /// <summary>
    /// Simple preprocessor of Wix preprocessor tokens.
    /// This uses simple string processing and Regex as tokens are not valid XML elements.
    /// Supported tokens and blocks:
    /// <?if ... ?> <?elseif ... ?> <?else?> <?endif?>
    /// <?ifdef ... ?>
    /// <?ifndef ... ?>
    /// </summary>
    /// <param name="sourceFilePath"></param>
    /// <exception cref="InvalidOperationException"></exception>
    private void PreprocessWixSourceFile(AbsolutePath sourceFilePath)
    {
        string input = File.ReadAllText(sourceFilePath);
        var output = new StringBuilder();

        int pos = 0;
        while (pos < input.Length)
        {
            // Find the next preprocessor block
            int ifStart = input.IndexOf("<?if ", pos, StringComparison.OrdinalIgnoreCase);
            int ifdefStart = input.IndexOf("<?ifdef ", pos, StringComparison.OrdinalIgnoreCase);
            int ifndefStart = input.IndexOf("<?ifndef ", pos, StringComparison.OrdinalIgnoreCase);

            // Find the earliest block
            int nextBlockStart = -1;
            string blockType = null;
            if (ifStart != -1 && (ifdefStart == -1 || ifStart < ifdefStart) && (ifndefStart == -1 || ifStart < ifndefStart))
            {
                nextBlockStart = ifStart;
                blockType = "if";
            }
            else if (ifdefStart != -1 && (ifndefStart == -1 || ifdefStart < ifndefStart))
            {
                nextBlockStart = ifdefStart;
                blockType = "ifdef";
            }
            else if (ifndefStart != -1)
            {
                nextBlockStart = ifndefStart;
                blockType = "ifndef";
            }

            if (nextBlockStart == -1)
            {
                // No more preprocessor blocks, copy the rest
                output.Append(input.Substring(pos));
                break;
            }

            // Copy up to the next block
            output.Append(input.Substring(pos, nextBlockStart - pos));

            if (blockType == "if")
            {
                int ifEnd = input.IndexOf("?>", nextBlockStart, StringComparison.OrdinalIgnoreCase);
                if (ifEnd == -1)
                {
                    throw new InvalidOperationException("Malformed <?if?> block.");
                }

                // Store all blocks and conditions
                var blocks = new List<(string condition, string content)>();
                // Parse the initial <?if ... ?>
                string ifCondition = input.Substring(nextBlockStart + 4, ifEnd - (nextBlockStart + 4)).Trim();
                int blockStart = ifEnd + 2;

                int searchPos = blockStart;
                int endifStart = input.IndexOf("<?endif?>", searchPos, StringComparison.OrdinalIgnoreCase);
                if (endifStart == -1)
                {
                    throw new InvalidOperationException("Missing <?endif?> for <?if?> block.");
                }

                // Find all <?elseif ... ?> and <?else?>
                var blockBoundaries = new List<(int start, int end, string condition, bool isElse)>();
                int nextTagStart = searchPos;
                string currentCondition = ifCondition;
                while (true)
                {
                    int elseifStart = input.IndexOf("<?elseif", nextTagStart, StringComparison.OrdinalIgnoreCase);
                    int elseStart = input.IndexOf("<?else?>", nextTagStart, StringComparison.OrdinalIgnoreCase);
                    int nextTag = -1;
                    string elseifCondition = null;

                    if ((elseifStart != -1 && elseifStart < endifStart) &&
                        (elseStart == -1 || elseifStart < elseStart))
                    {
                        nextTag = elseifStart;
                        int elseifEnd = input.IndexOf("?>", elseifStart, StringComparison.OrdinalIgnoreCase);
                        if (elseifEnd == -1)
                        {
                            throw new InvalidOperationException("Malformed <?elseif?> block.");
                        }

                        elseifCondition = input.Substring(elseifStart + 8, elseifEnd - (elseifStart + 8)).Trim();
                        blockBoundaries.Add((blockStart, elseifStart, currentCondition, false));
                        blockStart = elseifEnd + 2;
                        nextTagStart = blockStart;
                        currentCondition = elseifCondition;
                    }
                    else if (elseStart != -1 && elseStart < endifStart)
                    {
                        nextTag = elseStart;
                        blockBoundaries.Add((blockStart, elseStart, currentCondition, false));
                        blockStart = elseStart + 8;
                        nextTagStart = blockStart;
                        currentCondition = null;
                        break;
                    }
                    else
                    {
                        // No more elseif/else before endif
                        blockBoundaries.Add((blockStart, endifStart, currentCondition, false));
                        break;
                    }
                }

                // If there was an else, add its block
                if (currentCondition == null)
                {
                    blockBoundaries.Add((blockStart, endifStart, null, true));
                }

                // Find the first block whose condition evaluates to true, or else block
                string selectedContent = "";
                foreach (var (start, end, cond, isElse) in blockBoundaries)
                {
                    if (cond == null && isElse)
                    {
                        selectedContent = input.Substring(start, end - start);
                        break;
                    }
                    else if (cond != null && EvaluateCondition(cond))
                    {
                        selectedContent = input.Substring(start, end - start);
                        break;
                    }
                }

                output.Append(selectedContent);

                // Ingest variables from the selected content asap as
                // variables could be referenced in subsequent blocks or conditions.
                IngestDefineVariablesFromString(selectedContent);

                pos = endifStart + 9; // Move past <?endif?>
            }
            else if (blockType == "ifdef" || blockType == "ifndef")
            {
                int tagEnd = input.IndexOf("?>", nextBlockStart, StringComparison.OrdinalIgnoreCase);
                if (tagEnd == -1) throw new InvalidOperationException($"Malformed <?{blockType}?> block.");
                string variableName = input.Substring(nextBlockStart + (blockType == "ifdef" ? 7 : 8), tagEnd - (nextBlockStart + (blockType == "ifdef" ? 7 : 8))).Trim();

                bool isDefined;
                if (variableName.StartsWith("$(var.", StringComparison.OrdinalIgnoreCase) && variableName.EndsWith(")"))
                {
                    // Extract the variable name inside $(var.something)
                    string innerVar = variableName.Substring(6, variableName.Length - 7);
                    isDefined = _defineVariablesDictionary.ContainsKey(innerVar);
                }
                else if (variableName.StartsWith("$(") && variableName.EndsWith(")"))
                {
                    // Extract the variable name inside $(something)
                    string innerConst = variableName.Substring(2, variableName.Length - 3);
                    isDefined = _defineConstantsDictionary.ContainsKey(innerConst);
                }
                else
                {
                    // Fallback: treat as a plain variable name
                    isDefined = _defineConstantsDictionary.ContainsKey(variableName);
                }

                int endifStart = input.IndexOf("<?endif?>", tagEnd + 2, StringComparison.OrdinalIgnoreCase);
                if (endifStart == -1)
                {
                    throw new InvalidOperationException($"Missing <?endif?> for <?{blockType}?> block.");
                }

                int blockStart = tagEnd + 2;
                int blockEnd = endifStart;

                bool keepBlock = (blockType == "ifdef" && isDefined) || (blockType == "ifndef" && !isDefined);

                if (keepBlock)
                {
                    string selectedContent = input.Substring(blockStart, blockEnd - blockStart);
                    output.Append(selectedContent);

                    // Ingest variables from the selected content asap as
                    // variables could be referenced in subsequent blocks or conditions.
                    IngestDefineVariablesFromString(selectedContent);
                }
                pos = endifStart + 9; // Move past <?endif?>
            }
        }

        File.WriteAllText(sourceFilePath, output.ToString());
    }

    /// <summary>
    /// Simple conditions evaluator for Wix preprocessor conditions.
    /// Supports =, != (case-sensitive), ~= (case-insensitive), and quoted/unquoted values and variables.
    /// </summary>
    /// <param name="condition"></param>
    /// <returns></returns>
    /// <exception cref="NotSupportedException"></exception>
    private bool EvaluateCondition(string condition)
    {
        condition = condition.Trim();

        // Supports both:
        // $(Variable) <op> "value"
        // and
        // "value" <op> $(Variable)
        // Supports all combinations of quoted and unquoted values and variables.

        // Regex for both forms: variable on left or right, quoted or unquoted
        var eqMatch = Regex.Match(
            condition,
            @"^(?:(?:""?\$\(([^)]+)\)""?)\s*(=|!=|~=)\s*(?:""([^""]+)""|([^\s]+))|(?:""([^""]+)""|([^\s]+))\s*(=|!=|~=)\s*(""?\$\(([^)]+)\)""?))$"
        );

        if (!eqMatch.Success)
        {
            throw new NotSupportedException($"Unsupported condition: {condition}");
        }

        string varName, op, value, actualValue = "";

        if (!string.IsNullOrEmpty(eqMatch.Groups[1].Value))
        {
            // $(Variable) or "$(Variable)" <op> value
            varName = eqMatch.Groups[1].Value;
            op = eqMatch.Groups[2].Value;
            value = eqMatch.Groups[3].Success ? eqMatch.Groups[3].Value : eqMatch.Groups[4].Value;
        }
        else
        {
            // value <op> $(Variable) or "$(Variable)"
            value = eqMatch.Groups[5].Success ? eqMatch.Groups[5].Value : eqMatch.Groups[6].Value;
            op = eqMatch.Groups[7].Value;
            varName = eqMatch.Groups[9].Value;
        }

        // Trim quotes if present
        varName = varName.Trim('"');

        if (varName.StartsWith("var.", StringComparison.OrdinalIgnoreCase))
        {
            varName = varName.Substring(4);
            _defineVariablesDictionary.TryGetValue(varName, out actualValue);
        }
        else if (varName.StartsWith("sys.", StringComparison.OrdinalIgnoreCase))
        {
            varName = varName.Substring(4);
            _systemVariablesDictionary.TryGetValue(varName, out actualValue);
        }

        // Fallback to _defineConstantsDictionary if not found in variables
        if (actualValue == null || actualValue == "")
        {
            _defineConstantsDictionary.TryGetValue(varName, out actualValue);
        }

        switch (op)
        {
            case "=":
                return string.Equals(actualValue, value, StringComparison.Ordinal);
            case "!=":
                return !string.Equals(actualValue, value, StringComparison.Ordinal);
            case "~=":
                return string.Equals(actualValue, value, StringComparison.OrdinalIgnoreCase);
            default:
                throw new NotSupportedException($"Unsupported operator: {op}");
        }
    }

    private AbsolutePath CopySourceFile(string fileId, string source, string relativeRoot = "")
    {
        AbsolutePath destDir = TaskEnvironment.GetAbsolutePath(Path.Combine(WixpackWorkingDir, fileId));
        Directory.CreateDirectory(destDir);

        source = GetAbsoluteSourcePath(source, relativeRoot);
        AbsolutePath sourcePath = TaskEnvironment.GetAbsolutePath(source);

        if (File.Exists(sourcePath))
        {
            AbsolutePath destPath = TaskEnvironment.GetAbsolutePath(Path.Combine(destDir, Path.GetFileName(source)));
            File.Copy(sourcePath, destPath, overwrite: true);
            return destPath;
        }
        else
        {
            throw new FileNotFoundException($"Source file not found: {source}");
        }
    }

    private void CopyExtensions()
    {
        for (int i = 0; i < Extensions.Length; i++)
        {
            var extensionPath = Extensions[i].ItemSpec;
            string filename = Path.GetFileName(extensionPath);
            CopySourceFile(filename, extensionPath);

            // Update the extension item spec to the new relative path
            Extensions[i] = new TaskItem(Path.Combine(filename, filename));
        }
    }

    private void CopyBindPathContents()
    {
        for (int i = 0; i < BindPaths?.Length; i++)
        {
            // Skip items where BindName metadata is not present
            string bindName = BindPaths[i].GetMetadata("BindName");
            if (!string.IsNullOrEmpty(bindName))
            {
                string wixpackSubfolder = Path.GetRandomFileName();
                string bindPath = BindPaths[i].ItemSpec;

                AbsolutePath bindPathAbsolute = TaskEnvironment.GetAbsolutePath(bindPath);
                foreach (string file in Directory.GetFiles(bindPathAbsolute, "*", SearchOption.TopDirectoryOnly))
                {
                    // Copy known usable files only
                    // .dll, .exe, .msi
                    if (file.EndsWith(".dll") ||
                        file.EndsWith(".exe") ||
                        file.EndsWith(".msi"))
                    {
                        CopySourceFile(wixpackSubfolder, file);
                    }
                }

                // Update the bind path item spec to the new relative folder
                BindPaths[i].ItemSpec = wixpackSubfolder;
                continue;
            }
        }
    }

    private void CopyLocalizationFiles()
    {
        for (int i = 0; i < LocalizationFiles?.Length; i++)
        {
            var localizationPath = LocalizationFiles[i].ItemSpec;
            string filename = Path.GetFileName(localizationPath);
            CopySourceFile(filename, localizationPath);

            // Update the localization item spec to the new relative path
            LocalizationFiles[i] = new TaskItem(Path.Combine(filename, filename));
        }
    }

    private string GetAbsoluteSourcePath(string source, string relativeRoot = "")
    {
        // If the source is relative, resolve it against the project directory
        if (!Path.IsPathRooted(source))
        {
            return string.IsNullOrEmpty(relativeRoot) ?
                Path.Combine(_wixprojDir, source) :
                Path.Combine(relativeRoot, source);
        }

        return source;
    }

    private void CopyDirectoryRecursive(AbsolutePath sourceDir, AbsolutePath destDir)
    {
        Directory.CreateDirectory(destDir);

        foreach (var file in Directory.GetFiles(sourceDir))
        {
            AbsolutePath filePath = TaskEnvironment.GetAbsolutePath(file);
            AbsolutePath destinationFilePath = TaskEnvironment.GetAbsolutePath(Path.Combine(destDir, Path.GetFileName(file)));
            File.Copy(filePath, destinationFilePath, overwrite: true);
        }

        foreach (var dir in Directory.GetDirectories(sourceDir))
        {
            AbsolutePath subdirectoryPath = TaskEnvironment.GetAbsolutePath(dir);
            AbsolutePath destinationSubdirectoryPath = TaskEnvironment.GetAbsolutePath(Path.Combine(destDir, Path.GetFileName(dir)));
            CopyDirectoryRecursive(subdirectoryPath, destinationSubdirectoryPath);
        }
    }
}