/*
 * SimsAI Companion automation adapter for TS4 SimRipper.
 *
 * TS4 SimRipper is GPL-3.0-or-later software. This adapter is distributed
 * together with the corresponding modified source and license.
 */
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
using ProtoBuf;
using s4pi.Interfaces;
using s4pi.Package;
using TS4SaveGame = EA.Sims4.Persistence;

namespace TS4SimRipper
{
    internal sealed class SimsAiExportJob
    {
        public string game_path { get; set; }
        public string content_path { get; set; }
        public string mods_path { get; set; }
        public string saves_path { get; set; }
        public string save_path { get; set; }
        public string sim_data_path { get; set; }
        public string sim_data_sha256 { get; set; }
        public string source_mode { get; set; }
        public string output_dir { get; set; }
        public string result_path { get; set; }
        public string sim_id { get; set; }
        public int? outfit_category { get; set; }
        public int? outfit_index { get; set; }
        public bool hq_textures { get; set; } = true;
        public bool strict_cc { get; set; } = false;
        public bool allow_partial_cc { get; set; } = false;
        public string resource_scope { get; set; } = "full_outfit";
        public string mod_index_path { get; set; }
        public string mod_index_signature { get; set; }
    }

    internal sealed class SimsAiExportResult
    {
        public string status { get; set; }
        public string message { get; set; }
        public string sim_id { get; set; }
        public string sim_name { get; set; }
        public string save_path { get; set; }
        public string sim_data_path { get; set; }
        public string sim_data_sha256 { get; set; }
        public string model_path { get; set; }
        public List<string> texture_paths { get; set; } = new List<string>();
        public List<string> cc_packages { get; set; } = new List<string>();
        public List<string> cas_parts { get; set; } = new List<string>();
        public List<string> unresolved_resources { get; set; } = new List<string>();
        public bool cc_complete { get; set; }
        public int expected_cas_part_count { get; set; }
        public int resolved_cas_part_count { get; set; }
        public int outfit_category { get; set; }
        public int outfit_index { get; set; }
        public string resource_scope { get; set; }
        public string mod_index_signature { get; set; }
        public bool face_cc_complete { get; set; }
        public bool partial_cc { get; set; }
        public bool model_usable { get; set; }
        public int indexed_mod_package_count { get; set; }
        public int selected_mod_package_count { get; set; }
        public int skipped_package_count { get; set; }
        public List<string> skipped_resources { get; set; } = new List<string>();
        public List<string> omitted_cas_parts { get; set; } = new List<string>();
        public string diagnostics { get; set; }
        public string extractor { get; set; } = "TS4 SimRipper 3.14.2.11 + SimsAI adapter";
    }

    internal static class AutomationExport
    {
        private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
        {
            PropertyNameCaseInsensitive = true,
            WriteIndented = true
        };

        internal static int Run(string jobPath)
        {
            SimsAiExportJob job = null;
            SimsAiExportResult result = new SimsAiExportResult { status = "failed" };
            try
            {
                job = JsonSerializer.Deserialize<SimsAiExportJob>(File.ReadAllText(jobPath), JsonOptions)
                      ?? throw new InvalidDataException("Export job is empty.");
                ValidateJob(job);
                ConfigurePaths(job);
                Directory.CreateDirectory(job.output_dir);

                Form1.AutomationModPackagePaths = null;
                Form1.AutomationIndexedModPackages = null;
                Form1.AutomationExpectedCasParts = null;
                Form1.AutomationResourceScope = job.resource_scope ?? "full_outfit";
                Form1.AutomationModIndexSignature = job.mod_index_signature ?? "";
                Form1.AutomationIndexedPackageCount = 0;
                Form1.AutomationSkippedPackageCount = 0;
                Form1.AutomationPlanWarnings = new List<string>();
                SimsAiExportPlan plan = AutomationModIndex.PrepareExport(job);
                if (plan != null)
                {
                    Form1.AutomationModPackagePaths = plan.package_paths.ToArray();
                    Form1.AutomationIndexedModPackages = plan.indexed_packages;
                    Form1.AutomationExpectedCasParts = plan.expected_cas_parts;
                    Form1.AutomationIndexedPackageCount = plan.indexed_package_count;
                    Form1.AutomationSkippedPackageCount = plan.skipped_package_count;
                    Form1.AutomationPlanWarnings = plan.warnings;
                }

                using (Form1 form = new Form1())
                {
                    result = form.ExportForSimsAi(job);
                }
                WriteResult(job, result);
                return result.status == "completed" ? 0 : 2;
            }
            catch (Exception ex)
            {
                result.status = "failed";
                result.message = ex.Message;
                result.diagnostics = ex.ToString();
                try
                {
                    if (job != null) WriteResult(job, result);
                    else File.WriteAllText(Path.ChangeExtension(jobPath, ".result.json"), JsonSerializer.Serialize(result, JsonOptions));
                }
                catch { }
                return 1;
            }
        }

        private static void ValidateJob(SimsAiExportJob job)
        {
            if (!Directory.Exists(job.game_path)) throw new DirectoryNotFoundException("The Sims 4 game folder was not found: " + job.game_path);
            if (!Directory.Exists(job.mods_path)) throw new DirectoryNotFoundException("The Sims 4 Mods folder was not found: " + job.mods_path);
            bool hasSave = !string.IsNullOrWhiteSpace(job.save_path);
            bool hasLiveSimData = !string.IsNullOrWhiteSpace(job.sim_data_path);
            if (hasSave == hasLiveSimData) throw new InvalidDataException("Exactly one of save_path or sim_data_path is required.");
            if (hasSave && !File.Exists(job.save_path)) throw new FileNotFoundException("The save snapshot was not found.", job.save_path);
            if (hasLiveSimData && !File.Exists(job.sim_data_path)) throw new FileNotFoundException("The live SimData snapshot was not found.", job.sim_data_path);
            if (string.IsNullOrWhiteSpace(job.output_dir)) throw new InvalidDataException("output_dir is required.");
            if (!ulong.TryParse(job.sim_id, out _)) throw new InvalidDataException("sim_id must be an unsigned integer string.");
            if (hasLiveSimData &&
                (string.IsNullOrWhiteSpace(job.sim_data_sha256)
                 || job.sim_data_sha256.Length != 64
                 || job.sim_data_sha256.Any(ch => !Uri.IsHexDigit(ch))))
            {
                throw new InvalidDataException("A valid live SimData SHA-256 is required.");
            }
            if (hasLiveSimData)
            {
                string actual = Sha256(job.sim_data_path);
                if (!string.Equals(actual, job.sim_data_sha256, StringComparison.OrdinalIgnoreCase))
                    throw new InvalidDataException("The live SimData SHA-256 does not match the capture receipt.");
                if (string.Equals(job.resource_scope, "head_face", StringComparison.OrdinalIgnoreCase)
                    || string.Equals(job.resource_scope, "full_outfit", StringComparison.OrdinalIgnoreCase))
                {
                    if (string.IsNullOrWhiteSpace(job.mod_index_path) || !File.Exists(job.mod_index_path))
                        throw new FileNotFoundException("A completed Mods folder index is required.", job.mod_index_path);
                    if (string.IsNullOrWhiteSpace(job.mod_index_signature))
                        throw new InvalidDataException("mod_index_signature is required for an indexed current-Sim export.");
                }
            }
        }

        private static void ConfigurePaths(SimsAiExportJob job)
        {
            Properties.Settings.Default.TS4Path = Path.GetFullPath(job.game_path);
            Properties.Settings.Default.TS4ContentPath = string.IsNullOrWhiteSpace(job.content_path) ? " " : Path.GetFullPath(job.content_path);
            Properties.Settings.Default.TS4ModsPath = Path.GetFullPath(job.mods_path);
            Properties.Settings.Default.TS4SavesPath = !string.IsNullOrWhiteSpace(job.saves_path)
                ? Path.GetFullPath(job.saves_path)
                : (!string.IsNullOrWhiteSpace(job.save_path)
                    ? Path.GetDirectoryName(Path.GetFullPath(job.save_path))
                    : " ");
            Properties.Settings.Default.HQTextures = job.hq_textures;
            Properties.Settings.Default.LinkTextures = true;
            Properties.Settings.Default.BlenderClean = false;
            Properties.Settings.Default.SeparateMeshesIndex = 2;
            Properties.Settings.Default.ConvertBump = true;
        }

        internal static string Sha256(string path)
        {
            using (SHA256 digest = SHA256.Create())
            using (FileStream stream = File.OpenRead(path))
            {
                return BitConverter.ToString(digest.ComputeHash(stream)).Replace("-", "");
            }
        }

        private static void WriteResult(SimsAiExportJob job, SimsAiExportResult result)
        {
            string path = string.IsNullOrWhiteSpace(job.result_path)
                ? Path.Combine(job.output_dir, "export_result.json")
                : job.result_path;
            Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
            string temp = path + ".tmp";
            File.WriteAllText(temp, JsonSerializer.Serialize(result, JsonOptions));
            File.Move(temp, path, true);
        }
    }

    public partial class Form1
    {
        internal SimsAiExportResult ExportForSimsAi(SimsAiExportJob job)
        {
            ulong targetId = ulong.Parse(job.sim_id);
            TS4SaveGame.SaveGameData save = null;
            TS4SaveGame.SimData sim = null;
            Package package = null;
            bool liveCapture = !string.IsNullOrWhiteSpace(job.sim_data_path);
            if (liveCapture)
            {
                using (Stream stream = File.OpenRead(job.sim_data_path))
                {
                    sim = Serializer.Deserialize<TS4SaveGame.SimData>(stream);
                }
                if (sim == null) throw new InvalidDataException("The live SimData snapshot could not be decoded.");
                if (sim.sim_id != targetId) throw new InvalidDataException("The live SimData belongs to a different Sim.");
            }
            else
            {
                package = (Package)Package.OpenPackage(0, job.save_path, false);
                Predicate<IResourceIndexEntry> saveResource = r => r.ResourceType == 0x0000000D;
                IResourceIndexEntry entry = package.Find(saveResource)
                    ?? throw new InvalidDataException("No readable Sims 4 save resource was found in the snapshot.");
                using (Stream stream = package.GetResource(entry))
                {
                    save = Serializer.Deserialize<TS4SaveGame.SaveGameData>(stream);
                }
                sim = save.sims?.FirstOrDefault(x => x.sim_id == targetId);
                if (sim == null) throw new InvalidDataException("The synchronized Sim was not found in this save snapshot.");
            }

            worldNames = new Dictionary<ulong, string>();
            if (save?.zones != null && save.neighborhoods != null)
            {
                foreach (TS4SaveGame.ZoneData zone in save.zones)
                {
                    TS4SaveGame.NeighborhoodData neighborhood = save.neighborhoods.FirstOrDefault(x => x.neighborhood_id == zone.neighborhood_id);
                    if (neighborhood != null && !worldNames.ContainsKey(zone.zone_id)) worldNames.Add(zone.zone_id, neighborhood.name);
                }
            }
            currentSaveGame = package;
            currentSaveName = liveCapture
                ? "Live current Sim"
                : save.save_slot?.slot_name ?? Path.GetFileNameWithoutExtension(job.save_path);
            GameName.Text = currentSaveName;
            SaveGameFile.Text = liveCapture ? job.sim_data_path : job.save_path;

            SimListing listing = new SimListing(sim);
            simsArray = new[] { listing };
            sims_listBox.Items.Clear();
            sims_listBox.Items.Add(listing);
            sims_listBox.SelectedIndex = 0; // Builds the fully morphed model and all composite textures.

            int displayIndex = FindRequestedOutfit(sim, job.outfit_category, job.outfit_index, out int category, out int categoryIndex);
            if (displayIndex < 0 || displayIndex >= Outfits_comboBox.Items.Count)
                throw new InvalidDataException("The requested live outfit could not be mapped to a renderable outfit.");

            string initialBuildErrors = errorList;
            errorList = "";
            if (Outfits_comboBox.SelectedIndex != displayIndex)
            {
                Outfits_comboBox.SelectedIndexChanged -= Outfits_comboBox_SelectedIndexChanged;
                Outfits_comboBox.SelectedIndex = displayIndex;
                Outfits_comboBox.SelectedIndexChanged += Outfits_comboBox_SelectedIndexChanged;
                GetCurrentModel();
            }
            // Selecting the live Sim already builds its current outfit.  The
            // requested outfit is that same capture-time outfit in the normal
            // path, so do not build and composite it a second time.
            outfitIndex = displayIndex;

            string safeName = SanitizeName(sim.first_name + "_" + sim.last_name);
            string basename = safeName + "_" + sim.sim_id;
            string modelPath = Path.Combine(job.output_dir, basename + ".dae");
            ExportCurrentDae(modelPath, basename);

            List<string> texturePaths = SaveCurrentTextures(Path.Combine(job.output_dir, basename));
            string finalBuildErrors = errorList;
            TS4SaveGame.OutfitData selectedOutfit = GetOutfitForDisplayIndex(sim, outfitIndex);
            if (selectedOutfit?.parts?.ids == null)
                throw new InvalidDataException("The requested live outfit has no CAS part list.");
            bool headFaceScope = string.Equals(job.resource_scope, "head_face", StringComparison.OrdinalIgnoreCase);
            bool indexedScope = liveCapture && AutomationModPackagePaths != null;
            HashSet<ulong> allPartIds = new HashSet<ulong>(selectedOutfit.parts.ids);
            HashSet<ulong> expectedPartIds = headFaceScope
                ? AutomationModIndex.HeadFaceCasParts(selectedOutfit)
                : allPartIds;
            List<string> casParts = expectedPartIds
                .Select(x => "0x" + x.ToString("X16"))
                .OrderBy(x => x)
                .ToList();
            List<string> omittedCasParts = allPartIds
                .Where(x => !expectedPartIds.Contains(x))
                .Select(x => "0x" + x.ToString("X16"))
                .OrderBy(x => x)
                .ToList();

            HashSet<ulong> resolvedPartIds = new HashSet<ulong>(
                currentOutfits[outfitIndex].casps.Where(x => x != null).Select(x => x.tgi.Instance));
            List<string> unresolved = expectedPartIds
                .Where(x => !resolvedPartIds.Contains(x))
                .Select(x => "Missing CASP 0x" + x.ToString("X16"))
                .ToList();
            unresolved.AddRange(StrictResourceFailures(initialBuildErrors, expectedPartIds, true));
            unresolved.AddRange(StrictResourceFailures(finalBuildErrors, expectedPartIds, indexedScope));
            unresolved = unresolved.Distinct(StringComparer.OrdinalIgnoreCase).ToList();

            List<string> packages = currentOutfits != null && outfitIndex >= 0 && outfitIndex < currentOutfits.Count
                ? currentOutfits[outfitIndex].casps
                    .Where(x => x != null && x.notBaseGame && !string.IsNullOrWhiteSpace(x.package))
                    .Select(x => Path.GetFullPath(x.package))
                    .Where(x => IsInsideDirectory(job.mods_path, x))
                    .Distinct(StringComparer.OrdinalIgnoreCase)
                    .ToList()
                : new List<string>();

            if (indexedScope)
            {
                packages.AddRange(OpenedIndexedModPackages.Select(Path.GetFullPath));
                packages = packages
                    .Where(x => IsInsideDirectory(job.mods_path, x))
                    .Distinct(StringComparer.OrdinalIgnoreCase)
                    .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
                    .ToList();
            }

            bool scopedComplete = unresolved.Count == 0;
            bool ccComplete = !headFaceScope && scopedComplete;
            bool publishPartial = indexedScope && job.allow_partial_cc;
            List<string> skippedResources = new List<string>(unresolved);
            if (indexedScope && AutomationSkippedPackageCount > 0)
                skippedResources.Add("Mods index skipped " + AutomationSkippedPackageCount + " unreadable package(s).");
            string completionMessage = indexedScope
                ? (headFaceScope
                    ? (scopedComplete
                    ? "Live current-Sim head/face model and scoped CC were exported; clothing CC was intentionally omitted."
                    : "Live current-Sim head/face model was exported with unavailable CC skipped: "
                        + string.Join(" | ", unresolved.Take(12)))
                    : (scopedComplete
                        ? "Live current-Sim full body, current outfit and matching installed CC were exported."
                        : "Live current-Sim full body/current outfit model was exported with unavailable resources skipped: "
                            + string.Join(" | ", unresolved.Take(12))))
                : (scopedComplete
                    ? "Exact live Sim mesh, rig, current outfit, installed CC and high-resolution textures were exported."
                    : "Current-Sim CC/resource resolution is incomplete: " + string.Join(" | ", unresolved.Take(12)));

            return new SimsAiExportResult
            {
                status = (!job.strict_cc || scopedComplete || publishPartial) ? "completed" : "failed",
                message = completionMessage,
                sim_id = sim.sim_id.ToString(),
                sim_name = (sim.first_name + " " + sim.last_name).Trim(),
                save_path = job.save_path,
                sim_data_path = job.sim_data_path,
                sim_data_sha256 = liveCapture ? AutomationExport.Sha256(job.sim_data_path) : "",
                model_path = modelPath,
                texture_paths = texturePaths,
                cc_packages = packages,
                cas_parts = casParts,
                unresolved_resources = unresolved,
                cc_complete = ccComplete,
                expected_cas_part_count = expectedPartIds.Count,
                resolved_cas_part_count = resolvedPartIds.Count(x => expectedPartIds.Contains(x)),
                outfit_category = category,
                outfit_index = categoryIndex,
                resource_scope = headFaceScope ? "head_face" : "full_outfit",
                mod_index_signature = indexedScope ? (job.mod_index_signature ?? "") : "",
                face_cc_complete = headFaceScope && scopedComplete,
                partial_cc = indexedScope && (!scopedComplete || AutomationSkippedPackageCount > 0),
                model_usable = true,
                indexed_mod_package_count = indexedScope ? AutomationIndexedPackageCount : 0,
                selected_mod_package_count = packages.Count,
                skipped_package_count = indexedScope ? AutomationSkippedPackageCount : 0,
                skipped_resources = skippedResources.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
                omitted_cas_parts = omittedCasParts,
                diagnostics = string.Join(Environment.NewLine, new[] { automationWarnings, initialBuildErrors, finalBuildErrors }.Where(x => !string.IsNullOrWhiteSpace(x)))
            };
        }

        private int FindRequestedOutfit(TS4SaveGame.SimData sim, int? requestedCategory, int? requestedIndex,
            out int actualCategory, out int actualIndex)
        {
            int category = requestedCategory ?? (int)sim.current_outfit_type;
            int categoryIndex = requestedIndex ?? (int)sim.current_outfit_index;
            int displayIndex = 0;
            int foundInCategory = 0;
            foreach (TS4SaveGame.OutfitData outfit in sim.outfits.outfits)
            {
                if (outfit.parts?.ids == null) continue;
                if ((int)outfit.category == category)
                {
                    if (foundInCategory == categoryIndex)
                    {
                        actualCategory = category;
                        actualIndex = categoryIndex;
                        return displayIndex;
                    }
                    foundInCategory++;
                }
                displayIndex++;
            }

            if (requestedCategory.HasValue && requestedIndex.HasValue)
            {
                throw new InvalidDataException(
                    "The exact requested outfit " + requestedCategory.Value + "/" + requestedIndex.Value
                    + " is not present in the live SimData snapshot.");
            }

            actualCategory = (int)sim.current_outfit_type;
            actualIndex = (int)sim.current_outfit_index;
            displayIndex = 0;
            foundInCategory = 0;
            foreach (TS4SaveGame.OutfitData outfit in sim.outfits.outfits)
            {
                if (outfit.parts?.ids == null) continue;
                if ((int)outfit.category == actualCategory)
                {
                    if (foundInCategory == actualIndex) return displayIndex;
                    foundInCategory++;
                }
                displayIndex++;
            }
            actualCategory = sim.outfits.outfits.FirstOrDefault(x => x.parts?.ids != null)?.category is uint firstCategory ? (int)firstCategory : 0;
            actualIndex = 0;
            return 0;
        }

        private TS4SaveGame.OutfitData GetOutfitForDisplayIndex(TS4SaveGame.SimData sim, int displayIndex)
        {
            int current = 0;
            foreach (TS4SaveGame.OutfitData outfit in sim.outfits.outfits)
            {
                if (outfit.parts?.ids == null) continue;
                if (current == displayIndex) return outfit;
                current++;
            }
            return null;
        }

        private static bool IsInsideDirectory(string directory, string candidate)
        {
            if (string.IsNullOrWhiteSpace(directory) || string.IsNullOrWhiteSpace(candidate)) return false;
            string root = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
                + Path.DirectorySeparatorChar;
            string full = Path.GetFullPath(candidate);
            return full.StartsWith(root, StringComparison.OrdinalIgnoreCase);
        }

        private static List<string> StrictResourceFailures(
            string errors,
            HashSet<ulong> selectedCasParts,
            bool ignoreOtherOutfitCasps)
        {
            List<string> result = new List<string>();
            if (string.IsNullOrWhiteSpace(errors)) return result;
            string[] failureMarkers = new[]
            {
                "can't find", "can't get", "can't read", "could not read",
                "could not extract", "not found", "doesn't match", "out of bounds"
            };
            using (StringReader reader = new StringReader(errors))
            {
                string line;
                while ((line = reader.ReadLine()) != null)
                {
                    string trimmed = line.Trim();
                    if (trimmed.Length == 0) continue;
                    string lower = trimmed.ToLowerInvariant();
                    if (!failureMarkers.Any(marker => lower.Contains(marker))) continue;
                    if (ignoreOtherOutfitCasps && lower.Contains(" casp "))
                    {
                        bool selected = selectedCasParts.Any(id =>
                            lower.Contains(id.ToString("x16").ToLowerInvariant()));
                        if (!selected) continue;
                    }
                    result.Add(trimmed);
                }
            }
            return result;
        }

        private void ExportCurrentDae(string modelPath, string basename)
        {
            if (CurrentModel == null || currentRig == null) throw new InvalidOperationException("The Sim mesh or rig was not built.");
            List<GEOM> meshes = new List<GEOM>();
            List<string> names = new List<string>();
            GEOM solid = null;
            GEOM glass = null;
            GEOM wings = null;
            for (int i = CurrentModel.Length - 1; i >= 0; i--)
            {
                if (CurrentModel[i] != null)
                {
                    if (solid == null) solid = new GEOM(CurrentModel[i]); else solid.AppendMesh(CurrentModel[i]);
                }
                if (GlassModel[i] != null)
                {
                    if (glass == null) glass = new GEOM(GlassModel[i]); else glass.AppendMesh(GlassModel[i]);
                }
                if (WingsModel[i] != null)
                {
                    if (wings == null) wings = new GEOM(WingsModel[i]); else wings.AppendMesh(WingsModel[i]);
                }
            }
            if (solid != null) { meshes.Add(solid); names.Add(basename); }
            if (glass != null) { meshes.Add(glass); names.Add(basename + "_glass"); }
            if (wings != null) { meshes.Add(wings); names.Add(basename + "_wings"); }
            if (meshes.Count == 0) throw new InvalidOperationException("No visible mesh could be resolved for this outfit.");

            DAE dae = new DAE(meshes.ToArray(), names.ToArray(), currentRig, false, false, ref errorList);
            float boneDivider = ((10f - (float)BoneSize_numericUpDown.Value) / 4f) * 100f;
            dae.Write(modelPath, false, boneDivider, true, glass != null, wings != null);
        }

        private List<string> SaveCurrentTextures(string prefix)
        {
            List<string> paths = new List<string>();
            SaveTexture(currentTexture, prefix + "_diffuse.png", paths);
            SaveTexture(currentSpecular, prefix + "_specular.png", paths);
            SaveTexture(currentGlassTexture, prefix + "_glass_diffuse.png", paths);
            SaveTexture(currentGlassSpecular, prefix + "_glass_specular.png", paths);
            SaveTexture(currentBump == null ? null : (NormalConvert_checkBox.Checked ? ConvertNormalMap(currentBump) : currentBump), prefix + "_normalmap.png", paths);
            SaveTexture(currentEmission, prefix + "_emissionmap.png", paths);
            SaveTexture(currentWingsTexture, prefix + "_wings_diffuse.png", paths);
            SaveTexture(currentWingsSpecular, prefix + "_wings_specular.png", paths);
            return paths;
        }

        private void SaveTexture(System.Drawing.Image image, string path, List<string> paths)
        {
            if (image == null) return;
            SaveImagePng(image, path);
            paths.Add(path);
        }

        private static string SanitizeName(string value)
        {
            foreach (char invalid in Path.GetInvalidFileNameChars()) value = value.Replace(invalid, '_');
            value = value.Replace(' ', '_').Trim('_');
            return string.IsNullOrWhiteSpace(value) ? "Sim" : value;
        }
    }
}
