/*
 * Expression Director local Mods index adapter.
 *
 * This file is distributed under TS4SimRipper's GPL terms together with the
 * corresponding modified source.  It deliberately records only resource
 * identifiers and local paths; no game or CC payload is copied into the
 * editor index.
 */
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using ProtoBuf;
using s4pi.Interfaces;
using s4pi.Package;
using TS4SaveGame = EA.Sims4.Persistence;

namespace TS4SimRipper
{
    internal sealed class SimsAiModIndexJob
    {
        public string mods_path { get; set; }
        public string previous_index_path { get; set; }
        public string result_path { get; set; }
    }

    internal sealed class SimsAiIndexedPackage
    {
        public string path { get; set; }
        public string relative_path { get; set; }
        public long size { get; set; }
        public long last_write_filetime { get; set; }
        public bool readable { get; set; }
        public string error { get; set; }
        public List<string> resource_keys { get; set; } = new List<string>();
    }

    internal sealed class SimsAiModIndexResult
    {
        public int schema_version { get; set; } = 2;
        public string status { get; set; }
        public string message { get; set; }
        public string mods_path { get; set; }
        public string generated_at_utc { get; set; }
        public int package_count { get; set; }
        public int readable_count { get; set; }
        public int scanned_count { get; set; }
        public int reused_count { get; set; }
        public int skipped_count { get; set; }
        public List<string> skipped_packages { get; set; } = new List<string>();
        public List<SimsAiIndexedPackage> packages { get; set; } = new List<SimsAiIndexedPackage>();
    }

    internal sealed class SimsAiExportPlan
    {
        public HashSet<ulong> expected_cas_parts { get; } = new HashSet<ulong>();
        public List<string> package_paths { get; } = new List<string>();
        public List<string> warnings { get; } = new List<string>();
        public List<SimsAiIndexedPackage> indexed_packages { get; } = new List<SimsAiIndexedPackage>();
        public int indexed_package_count { get; set; }
        public int skipped_package_count { get; set; }
    }

    internal static class AutomationModIndex
    {
        private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
        {
            PropertyNameCaseInsensitive = true,
            // This file can contain millions of resource addresses.  Compact JSON
            // keeps the persistent index cheap to load on every manual Sim read.
            WriteIndented = false
        };

        private static readonly HashSet<uint> IndexedTypes = new HashSet<uint>
        {
            (uint)ResourceTypes.CASP,
            (uint)ResourceTypes.BlendGeometry,
            (uint)ResourceTypes.DeformerMap,
            (uint)ResourceTypes.BoneDelta,
            (uint)ResourceTypes.GEOM,
            (uint)ResourceTypes.RMap,
            (uint)ResourceTypes.RLE2,
            (uint)ResourceTypes.RLES,
            (uint)ResourceTypes.LRLE,
            (uint)ResourceTypes.DDS,
            (uint)ResourceTypes.DDSuncompressed,
            (uint)ResourceTypes.TONE,
            (uint)ResourceTypes.Sculpt,
            (uint)ResourceTypes.SimModifier,
            (uint)ResourceTypes.PeltLayer,
            (uint)ResourceTypes.Rig,
            (uint)ResourceTypes.Tuning1,
            (uint)ResourceTypes.Tuning2
        };

        internal static int Run(string jobPath)
        {
            SimsAiModIndexJob job = null;
            SimsAiModIndexResult result = new SimsAiModIndexResult { status = "failed" };
            try
            {
                job = JsonSerializer.Deserialize<SimsAiModIndexJob>(File.ReadAllText(jobPath), JsonOptions)
                      ?? throw new InvalidDataException("Mods index job is empty.");
                if (string.IsNullOrWhiteSpace(job.mods_path) || !Directory.Exists(job.mods_path))
                    throw new DirectoryNotFoundException("The Sims 4 Mods folder was not found: " + job.mods_path);
                if (string.IsNullOrWhiteSpace(job.result_path))
                    throw new InvalidDataException("result_path is required.");

                string modsRoot = Path.GetFullPath(job.mods_path);
                Dictionary<string, SimsAiIndexedPackage> previous = LoadPrevious(job.previous_index_path, modsRoot);
                string[] files = Directory.GetFiles(modsRoot, "*.package", SearchOption.AllDirectories);
                // Match TS4 SimRipper's historical Mods load order exactly.
                Array.Sort(files);
                result.mods_path = modsRoot;
                result.package_count = files.Length;

                foreach (string value in files)
                {
                    string fullPath = Path.GetFullPath(value);
                    string relativePath = Path.GetRelativePath(modsRoot, fullPath).Replace('\\', '/');
                    FileInfo info = new FileInfo(fullPath);
                    long filetime = info.LastWriteTimeUtc.ToFileTimeUtc();
                    if (previous.TryGetValue(relativePath, out SimsAiIndexedPackage cached)
                        && cached.size == info.Length
                        && cached.last_write_filetime == filetime)
                    {
                        cached.path = fullPath;
                        cached.relative_path = relativePath;
                        result.packages.Add(cached);
                        result.reused_count++;
                        if (!cached.readable)
                            result.skipped_packages.Add(relativePath + ": " + (cached.error ?? "unreadable package"));
                        continue;
                    }

                    SimsAiIndexedPackage row = ScanPackage(fullPath, relativePath, info.Length, filetime);
                    result.packages.Add(row);
                    result.scanned_count++;
                    if (!row.readable)
                        result.skipped_packages.Add(relativePath + ": " + row.error);
                }

                result.readable_count = result.packages.Count(x => x.readable);
                result.skipped_count = result.packages.Count - result.readable_count;
                result.status = "completed";
                result.generated_at_utc = DateTime.UtcNow.ToString("O");
                result.message = result.skipped_count == 0
                    ? "Mods folder index completed."
                    : "Mods folder index completed; unreadable packages were skipped.";
                WriteResult(job.result_path, result);
                return 0;
            }
            catch (Exception ex)
            {
                result.status = "failed";
                result.message = ex.Message;
                if (job != null && !string.IsNullOrWhiteSpace(job.result_path))
                {
                    try { WriteResult(job.result_path, result); } catch { }
                }
                return 1;
            }
        }

        private static SimsAiIndexedPackage ScanPackage(
            string fullPath,
            string relativePath,
            long size,
            long filetime)
        {
            SimsAiIndexedPackage row = new SimsAiIndexedPackage
            {
                path = fullPath,
                relative_path = relativePath,
                size = size,
                last_write_filetime = filetime,
                readable = false,
                error = ""
            };
            try
            {
                using (Package package = (Package)Package.OpenPackage(0, fullPath, false))
                {
                    foreach (IResourceIndexEntry entry in package.FindAll(r => IndexedTypes.Contains(r.ResourceType)))
                    {
                        row.resource_keys.Add(ResourceKey(entry));
                    }
                }
                row.resource_keys = row.resource_keys.Distinct(StringComparer.Ordinal).OrderBy(x => x).ToList();
                row.readable = true;
            }
            catch (Exception ex)
            {
                row.error = ex.Message;
            }
            return row;
        }

        private static Dictionary<string, SimsAiIndexedPackage> LoadPrevious(string path, string modsRoot)
        {
            if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
                return new Dictionary<string, SimsAiIndexedPackage>(StringComparer.OrdinalIgnoreCase);
            try
            {
                SimsAiModIndexResult result = JsonSerializer.Deserialize<SimsAiModIndexResult>(File.ReadAllText(path), JsonOptions);
                if (result == null || result.status != "completed" || result.schema_version != 2
                    || !string.Equals(Path.GetFullPath(result.mods_path ?? ""), modsRoot, StringComparison.OrdinalIgnoreCase))
                    return new Dictionary<string, SimsAiIndexedPackage>(StringComparer.OrdinalIgnoreCase);
                return (result.packages ?? new List<SimsAiIndexedPackage>())
                    .Where(x => x != null && !string.IsNullOrWhiteSpace(x.relative_path))
                    .GroupBy(x => x.relative_path, StringComparer.OrdinalIgnoreCase)
                    .ToDictionary(x => x.Key, x => x.First(), StringComparer.OrdinalIgnoreCase);
            }
            catch
            {
                return new Dictionary<string, SimsAiIndexedPackage>(StringComparer.OrdinalIgnoreCase);
            }
        }

        private static void WriteResult(string path, SimsAiModIndexResult result)
        {
            string fullPath = Path.GetFullPath(path);
            Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
            string temp = fullPath + ".tmp";
            File.WriteAllText(temp, JsonSerializer.Serialize(result, JsonOptions));
            File.Move(temp, fullPath, true);
        }

        internal static SimsAiExportPlan PrepareExport(SimsAiExportJob job)
        {
            bool headFaceScope = string.Equals(
                job.resource_scope, "head_face", StringComparison.OrdinalIgnoreCase);
            bool indexedFullOutfitScope = string.Equals(
                job.resource_scope, "full_outfit", StringComparison.OrdinalIgnoreCase)
                && !string.IsNullOrWhiteSpace(job.mod_index_path);
            if (!headFaceScope && !indexedFullOutfitScope)
                return null;
            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);

            SimsAiModIndexResult index = JsonSerializer.Deserialize<SimsAiModIndexResult>(
                File.ReadAllText(job.mod_index_path), JsonOptions);
            if (index == null || index.status != "completed" || index.schema_version != 2)
                throw new InvalidDataException("The Mods folder index is invalid or incomplete.");
            string modsRoot = Path.GetFullPath(job.mods_path);
            if (!string.Equals(Path.GetFullPath(index.mods_path ?? ""), modsRoot, StringComparison.OrdinalIgnoreCase))
                throw new InvalidDataException("The Mods folder index belongs to a different Mods folder.");

            TS4SaveGame.SimData sim;
            using (Stream stream = File.OpenRead(job.sim_data_path))
                sim = Serializer.Deserialize<TS4SaveGame.SimData>(stream);
            if (sim == null || sim.sim_id.ToString() != job.sim_id)
                throw new InvalidDataException("The indexed export SimData identity is invalid.");

            TS4SaveGame.OutfitData outfit = RequestedOutfit(sim, job.outfit_category, job.outfit_index);
            SimsAiExportPlan plan = new SimsAiExportPlan
            {
                indexed_package_count = index.package_count,
                skipped_package_count = index.skipped_count
            };
            IEnumerable<ulong> expectedParts = headFaceScope
                ? HeadFaceCasParts(outfit)
                : outfit?.parts?.ids ?? throw new InvalidDataException(
                    "The selected outfit has no CAS part list.");
            foreach (ulong id in expectedParts) plan.expected_cas_parts.Add(id);

            HashSet<ulong> tones = new HashSet<ulong>();
            HashSet<ulong> sculpts = new HashSet<ulong>();
            HashSet<ulong> modifiers = new HashSet<ulong>();
            HashSet<ulong> peltLayers = new HashSet<ulong>();
            if (sim.skin_tone != 0) tones.Add(sim.skin_tone);
            CollectFacialResources(sim.facial_attr, sculpts, modifiers);
            if (sim.pelt_layers?.layers != null)
                foreach (TS4SaveGame.PeltLayerData layer in sim.pelt_layers.layers)
                    if (layer.layer_id != 0) peltLayers.Add(layer.layer_id);
            if (sim.attributes?.occult_tracker?.occult_sim_infos != null)
            {
                foreach (TS4SaveGame.PersistableOccultTracker.OccultSimData occult in sim.attributes.occult_tracker.occult_sim_infos)
                {
                    if (occult.skin_tone != 0) tones.Add(occult.skin_tone);
                    CollectFacialResources(occult.facial_attributes, sculpts, modifiers);
                }
            }

            HashSet<TGI> wantedResources = new HashSet<TGI>();
            foreach (ulong value in plan.expected_cas_parts)
                wantedResources.Add(new TGI((uint)ResourceTypes.CASP, 0, value));
            foreach (ulong value in tones)
                wantedResources.Add(new TGI((uint)ResourceTypes.TONE, 0, value));
            foreach (ulong value in sculpts)
                wantedResources.Add(new TGI((uint)ResourceTypes.Sculpt, 0, value));
            foreach (ulong value in modifiers)
                wantedResources.Add(new TGI((uint)ResourceTypes.SimModifier, 0, value));
            foreach (ulong value in peltLayers)
                wantedResources.Add(new TGI((uint)ResourceTypes.PeltLayer, 0, value));

            foreach (SimsAiIndexedPackage row in index.packages ?? new List<SimsAiIndexedPackage>())
            {
                if (row == null || !row.readable) continue;
                string fullPath = Path.GetFullPath(row.path ?? "");
                if (!IsInsideDirectory(modsRoot, fullPath))
                    throw new InvalidDataException("The Mods index contains a package outside its Mods folder.");
                row.path = fullPath;
                plan.indexed_packages.Add(row);
                if (IntersectsResources(row.resource_keys, wantedResources))
                    plan.package_paths.Add(fullPath);
            }
            plan.indexed_packages.Sort((a, b) => Comparer<string>.Default.Compare(a.path, b.path));
            plan.package_paths.Sort();
            if (index.skipped_count > 0)
            {
                plan.warnings.Add("Mods index skipped " + index.skipped_count
                    + " unreadable package(s); any matching unavailable current-Sim CC will be reported and skipped.");
                plan.warnings.AddRange((index.skipped_packages ?? new List<string>()).Take(20));
            }
            return plan;
        }

        internal static HashSet<ulong> HeadFaceCasParts(TS4SaveGame.OutfitData outfit)
        {
            HashSet<ulong> result = new HashSet<ulong>();
            ulong[] ids = outfit?.parts?.ids;
            uint[] types = outfit?.body_types_list?.body_types;
            if (ids == null || types == null || ids.Length != types.Length)
                throw new InvalidDataException("The selected outfit has no aligned CAS body-type list.");
            for (int index = 0; index < ids.Length; index++)
                if (IsHeadFaceBodyType((BodyType)types[index])) result.Add(ids[index]);
            return result;
        }

        internal static bool IsHeadFaceBodyType(BodyType type)
        {
            switch (type)
            {
                case BodyType.Hat:
                case BodyType.Hair:
                case BodyType.Head:
                case BodyType.Face:
                case BodyType.Accessories:
                case BodyType.Earrings:
                case BodyType.Glasses:
                case BodyType.LipRingLeft:
                case BodyType.LipRingRight:
                case BodyType.NoseRingLeft:
                case BodyType.NoseRingRight:
                case BodyType.BrowRingLeft:
                case BodyType.BrowRingRight:
                case BodyType.FacialHair:
                case BodyType.Lipstick:
                case BodyType.Eyeshadow:
                case BodyType.Eyeliner:
                case BodyType.Blush:
                case BodyType.Facepaint:
                case BodyType.Eyebrows:
                case BodyType.Eyecolor:
                case BodyType.Mascara:
                case BodyType.ForeheadCrease:
                case BodyType.Freckles:
                case BodyType.DimpleLeft:
                case BodyType.DimpleRight:
                case BodyType.MoleLeftLip:
                case BodyType.MoleRightLip:
                case BodyType.MoleLeftCheek:
                case BodyType.MoleRightCheek:
                case BodyType.MouthCrease:
                case BodyType.SkinOverlay:
                case BodyType.Ears:
                case BodyType.NoseColor:
                case BodyType.SecondaryEyeColor:
                case BodyType.OccultBrow:
                case BodyType.OccultEyeSocket:
                case BodyType.OccultEyeLid:
                case BodyType.OccultMouth:
                case BodyType.OccultLeftCheek:
                case BodyType.OccultRightCheek:
                case BodyType.OccultNeckScar:
                case BodyType.SkinDetailScar:
                case BodyType.SkinDetailAcne:
                case BodyType.HairColor:
                case BodyType.TeenAcne:
                case BodyType.BirthmarkFace:
                case BodyType.MoleFace:
                case BodyType.BirthmarkOccult:
                case BodyType.TattooHead:
                case BodyType.Horn:
                case BodyType.HeadDeco:
                case BodyType.SkinSpecularity:
                    return true;
                default:
                    return false;
            }
        }

        private static TS4SaveGame.OutfitData RequestedOutfit(
            TS4SaveGame.SimData sim,
            int? requestedCategory,
            int? requestedIndex)
        {
            int category = requestedCategory ?? (int)sim.current_outfit_type;
            int index = requestedIndex ?? (int)sim.current_outfit_index;
            int found = 0;
            foreach (TS4SaveGame.OutfitData outfit in sim.outfits?.outfits ?? new List<TS4SaveGame.OutfitData>())
            {
                if (outfit.parts?.ids == null || (int)outfit.category != category) continue;
                if (found == index) return outfit;
                found++;
            }
            throw new InvalidDataException("The requested current outfit is not present in the live SimData snapshot.");
        }

        private static void CollectFacialResources(
            byte[] payload,
            HashSet<ulong> sculpts,
            HashSet<ulong> modifiers)
        {
            if (payload == null || payload.Length == 0) return;
            try
            {
                using (Stream stream = new MemoryStream(payload))
                {
                    TS4SaveGame.BlobSimFacialCustomizationData data =
                        Serializer.Deserialize<TS4SaveGame.BlobSimFacialCustomizationData>(stream);
                    if (data?.sculpts != null)
                        foreach (ulong value in data.sculpts) if (value != 0) sculpts.Add(value);
                    if (data?.face_modifiers != null)
                        foreach (TS4SaveGame.BlobSimFacialCustomizationData.Modifier value in data.face_modifiers)
                            if (value.key != 0) modifiers.Add(value.key);
                    if (data?.body_modifiers != null)
                        foreach (TS4SaveGame.BlobSimFacialCustomizationData.Modifier value in data.body_modifiers)
                            if (value.key != 0) modifiers.Add(value.key);
                    if (data?.aged_face_modifiers != null)
                        foreach (TS4SaveGame.BlobSimFacialCustomizationData.Modifier value in data.aged_face_modifiers)
                            if (value.key != 0) modifiers.Add(value.key);
                    if (data?.aged_body_modifiers != null)
                        foreach (TS4SaveGame.BlobSimFacialCustomizationData.Modifier value in data.aged_body_modifiers)
                            if (value.key != 0) modifiers.Add(value.key);
                }
            }
            catch
            {
                // Form1 will report a precise SimData build error later.  An
                // index planning failure must not invent or substitute CC.
            }
        }

        private static bool Intersects(IEnumerable<string> values, HashSet<ulong> wanted)
        {
            if (values == null || wanted.Count == 0) return false;
            foreach (string value in values)
            {
                string text = (value ?? "").Trim();
                if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) text = text.Substring(2);
                if (ulong.TryParse(text, System.Globalization.NumberStyles.HexNumber,
                    System.Globalization.CultureInfo.InvariantCulture, out ulong parsed)
                    && wanted.Contains(parsed)) return true;
            }
            return false;
        }

        internal static string ResourceKey(IResourceIndexEntry entry)
        {
            uint group = entry.ResourceType == (uint)ResourceTypes.CASP ? 0U : entry.ResourceGroup;
            return entry.ResourceType.ToString("X8") + "-" + group.ToString("X8") + "-"
                + entry.Instance.ToString("X16");
        }

        internal static bool TryParseResourceKey(string value, out TGI tgi)
        {
            tgi = default;
            string[] parts = (value ?? "").Split('-');
            if (parts.Length != 3
                || !uint.TryParse(parts[0], System.Globalization.NumberStyles.HexNumber,
                    System.Globalization.CultureInfo.InvariantCulture, out uint type)
                || !uint.TryParse(parts[1], System.Globalization.NumberStyles.HexNumber,
                    System.Globalization.CultureInfo.InvariantCulture, out uint group)
                || !ulong.TryParse(parts[2], System.Globalization.NumberStyles.HexNumber,
                    System.Globalization.CultureInfo.InvariantCulture, out ulong instance))
                return false;
            tgi = new TGI(type, group, instance);
            return true;
        }

        private static bool IntersectsResources(IEnumerable<string> values, HashSet<TGI> wanted)
        {
            if (values == null || wanted.Count == 0) return false;
            foreach (string value in values)
                if (TryParseResourceKey(value, out TGI parsed) && wanted.Contains(parsed)) return true;
            return false;
        }

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