// OG Sound Remaster - on-disk sound bank index
// Author: StixsworldHD (StixsmasterHD4k)

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;

namespace OGSoundRemaster.Audio
{
    /// <summary>One original-game sound file on disk.</summary>
    public sealed class BankFile
    {
        public string FullPath;
        public string RelPath;        // "hitsounds/bullet_hit_wood03"  (lower, no extension, '/')
        public string Folder;         // "hitsounds"          ("" for root)
        public string Name;           // "bullet_hit_wood03"
        public string GroupKey;       // "hitsounds/bullet_hit_wood"  (trailing digits stripped)
        public int VariantIndex;      // 3, or -1 when the name has no numeric suffix

        public int Channels;
        public int SampleRate;
        public int Frames;
        public int FormatTag;
        public long FileBytes;
        public float Duration;
        public long DecodedBytes;     // float32 footprint once resident in Unity

        public int Id;                // dense index into AudioBank.Files

        public override string ToString() => RelPath;
    }

    /// <summary>
    /// Scans the shipped OGSound tree once at start-up and builds every lookup structure the
    /// rest of the plugin needs. Header parsing only - no PCM is touched here, so a full scan of
    /// the 500+ file bank costs a few milliseconds and a few hundred KB.
    /// </summary>
    public sealed class AudioBank
    {
        public readonly List<BankFile> Files = new List<BankFile>();

        private readonly Dictionary<string, BankFile> _byRelPath = new Dictionary<string, BankFile>(StringComparer.Ordinal);
        private readonly Dictionary<string, List<BankFile>> _byName = new Dictionary<string, List<BankFile>>(StringComparer.Ordinal);
        private readonly Dictionary<string, List<BankFile>> _byGroup = new Dictionary<string, List<BankFile>>(StringComparer.Ordinal);
        private readonly Dictionary<string, List<BankFile>> _byFolder = new Dictionary<string, List<BankFile>>(StringComparer.Ordinal);

        public string RootPath { get; private set; }
        public int SkippedCount { get; private set; }
        public long TotalFileBytes { get; private set; }
        public long TotalDecodedBytes { get; private set; }

        private static readonly string[] Extensions = { ".wav", ".WAV", ".Wav" };

        public bool Scan(string root, Action<string> warn)
        {
            RootPath = root;
            if (!Directory.Exists(root)) return false;

            var buffer = new byte[64 * 1024];
            string[] paths;
            try { paths = Directory.GetFiles(root, "*", SearchOption.AllDirectories); }
            catch (Exception e) { warn?.Invoke("bank scan failed: " + e.Message); return false; }

            Array.Sort(paths, StringComparer.OrdinalIgnoreCase);

            foreach (var path in paths)
            {
                if (!path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) continue;

                long len;
                try { len = new FileInfo(path).Length; }
                catch { SkippedCount++; continue; }
                if (len < 44) { SkippedCount++; continue; }

                // Only the header is needed; 64 KB is comfortably past any sane chunk layout.
                int toRead = (int)Math.Min(len, buffer.Length);
                int got;
                try
                {
                    using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.SequentialScan))
                        got = ReadFully(fs, buffer, toRead);
                }
                catch { SkippedCount++; continue; }

                // TryReadFormat needs the data chunk header to be inside the window. If the file
                // has a huge LIST/INFO block up front we simply read the whole thing instead.
                WavFormat fmt;
                string err;
                if (!WavDecoder.TryReadFormat(buffer, got, len, out fmt, out err))
                {
                    if (got < len)
                    {
                        try
                        {
                            var whole = File.ReadAllBytes(path);
                            if (!WavDecoder.TryReadFormat(whole, whole.Length, whole.Length, out fmt, out err))
                            { warn?.Invoke("skipped " + path + ": " + err); SkippedCount++; continue; }
                        }
                        catch { SkippedCount++; continue; }
                    }
                    else { warn?.Invoke("skipped " + path + ": " + err); SkippedCount++; continue; }
                }

                // A data chunk larger than what we read is normal and fine - the length field is
                // authoritative and FrameCount only needs the header numbers.
                int frames = WavDecoder.FrameCount(in fmt);
                if (frames <= 0) { SkippedCount++; continue; }

                string rel = MakeRelKey(root, path);
                int slash = rel.LastIndexOf('/');
                string folder = slash < 0 ? string.Empty : rel.Substring(0, slash);
                string name = slash < 0 ? rel : rel.Substring(slash + 1);

                int variant;
                string bare = StripVariant(name, out variant);

                var bf = new BankFile
                {
                    FullPath = path,
                    RelPath = rel,
                    Folder = folder,
                    Name = name,
                    GroupKey = folder.Length == 0 ? bare : folder + "/" + bare,
                    VariantIndex = variant,
                    Channels = fmt.Channels,
                    SampleRate = fmt.SampleRate,
                    Frames = frames,
                    FormatTag = fmt.FormatTag,
                    FileBytes = len,
                    Duration = (float)frames / fmt.SampleRate,
                    DecodedBytes = (long)frames * fmt.Channels * sizeof(float),
                    Id = Files.Count
                };

                Files.Add(bf);
                TotalFileBytes += len;
                TotalDecodedBytes += bf.DecodedBytes;

                _byRelPath[rel] = bf;
                Add(_byName, name, bf);
                Add(_byGroup, bf.GroupKey, bf);
                Add(_byFolder, folder, bf);

                // The bare (unnumbered) name is also a useful alias: "expl" finds expl1/2/4/6/7.
                if (variant >= 0) Add(_byName, bare, bf);
            }

            foreach (var kv in _byGroup) kv.Value.Sort(CompareVariant);
            foreach (var kv in _byName) kv.Value.Sort(CompareVariant);

            return Files.Count > 0;
        }

        private static int ReadFully(Stream s, byte[] buf, int count)
        {
            int off = 0;
            while (off < count)
            {
                int n = s.Read(buf, off, count - off);
                if (n <= 0) break;
                off += n;
            }
            return off;
        }

        private static int CompareVariant(BankFile a, BankFile b)
        {
            int c = a.VariantIndex.CompareTo(b.VariantIndex);
            return c != 0 ? c : string.CompareOrdinal(a.RelPath, b.RelPath);
        }

        private static void Add(Dictionary<string, List<BankFile>> map, string key, BankFile v)
        {
            List<BankFile> list;
            if (!map.TryGetValue(key, out list)) { list = new List<BankFile>(4); map[key] = list; }
            list.Add(v);
        }

        /// <summary>Path relative to the bank root, lower-cased, forward slashes, extension removed.</summary>
        private static string MakeRelKey(string root, string path)
        {
            string rel = path.Substring(root.Length).Replace('\\', '/').TrimStart('/');
            int dot = rel.LastIndexOf('.');
            if (dot > rel.LastIndexOf('/')) rel = rel.Substring(0, dot);
            return rel.ToLowerInvariant();
        }

        /// <summary>"bullet_hit_wood03" -> "bullet_hit_wood" + 3. Returns -1 when unnumbered.</summary>
        public static string StripVariant(string name, out int index)
        {
            int end = name.Length;
            int i = end;
            while (i > 0 && name[i - 1] >= '0' && name[i - 1] <= '9') i--;

            if (i == end || i == 0) { index = -1; return name; }

            int n = 0;
            for (int k = i; k < end; k++) n = n * 10 + (name[k] - '0');
            index = n;

            // Trim a single separator left behind by the digits ("wave_rock1" -> "wave_rock").
            int cut = i;
            if (cut > 0 && (name[cut - 1] == '_' || name[cut - 1] == '-')) cut--;
            return cut == 0 ? name : name.Substring(0, cut);
        }

        // ---- lookup ---------------------------------------------------------

        /// <summary>Exact lookup by relative key, e.g. "avoice1/attack01" or "colt45-fire".</summary>
        public BankFile Get(string relKey)
        {
            if (string.IsNullOrEmpty(relKey)) return null;
            BankFile f;
            if (_byRelPath.TryGetValue(relKey, out f)) return f;

            List<BankFile> list;
            if (_byName.TryGetValue(relKey, out list) && list.Count > 0) return list[0];
            return null;
        }

        /// <summary>
        /// All numbered variants of a base name, e.g. "hitsounds/bullet_hit_wood" -> 7 files.
        /// Falls back to a single exact file, then to a name match, then to empty.
        /// </summary>
        public List<BankFile> Group(string groupKey)
        {
            if (string.IsNullOrEmpty(groupKey)) return Empty;

            List<BankFile> list;
            if (_byGroup.TryGetValue(groupKey, out list)) return list;

            BankFile one;
            if (_byRelPath.TryGetValue(groupKey, out one)) return new List<BankFile>(1) { one };

            int slash = groupKey.LastIndexOf('/');
            string bare = slash < 0 ? groupKey : groupKey.Substring(slash + 1);
            if (_byName.TryGetValue(bare, out list)) return list;

            return Empty;
        }

        /// <summary>Union of several group keys, de-duplicated, order preserved.</summary>
        public List<BankFile> Groups(params string[] groupKeys)
        {
            var outList = new List<BankFile>(8);
            var seen = new HashSet<int>();
            for (int i = 0; i < groupKeys.Length; i++)
            {
                var g = Group(groupKeys[i]);
                for (int k = 0; k < g.Count; k++)
                    if (seen.Add(g[k].Id)) outList.Add(g[k]);
            }
            return outList;
        }

        public List<BankFile> Folder(string folder)
        {
            List<BankFile> list;
            return _byFolder.TryGetValue(folder, out list) ? list : Empty;
        }

        public bool Has(string relKey) => Get(relKey) != null;

        public static readonly List<BankFile> Empty = new List<BankFile>(0);

        public string Describe()
        {
            var sb = new StringBuilder();
            sb.Append(Files.Count).Append(" files, ")
              .Append((TotalFileBytes / 1048576.0).ToString("F1")).Append(" MB on disk, ")
              .Append((TotalDecodedBytes / 1048576.0).ToString("F0")).Append(" MB if fully resident");
            if (SkippedCount > 0) sb.Append(", ").Append(SkippedCount).Append(" skipped");
            return sb.ToString();
        }
    }
}
