using System;
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
using UnityEngine.Networking;

namespace Davanci.Nexus
{
    // Remote config + A/B testing served by the DavanciNexus dashboard over Firebase
    // Realtime Database REST. Values resolve remote → cached → the default you pass in.
    public static class NexusRemoteConfig
    {
        // Fixed by the platform: every game reads its config from the same database, so this
        // is deliberately not a setting anyone can get wrong.
        public const string DatabaseUrl = "https://davancinexus-default-rtdb.firebaseio.com/";

        const string ConfigNode = "remote_config";
        const string KeyPayload = "nexus_rc_payload";
        const string KeyFetchedAt = "nexus_rc_fetched_at";
        const string KeyInstallId = "nexus_rc_install_id";

        [Serializable]
        public class Entry
        {
            public string k;
            public string v;
        }

        [Serializable]
        public class Variant
        {
            public string name;
            public int weight;
            public Entry[] values;
        }

        [Serializable]
        public class Experiment
        {
            public string key;
            public string salt;
            // GA4 user-scoped custom dimension this experiment reports into, allocated by the
            // dashboard (e.g. "rc_exp1"). Empty means event-only reporting.
            public string property;
            public Variant[] variants;
        }

        [Serializable]
        public class Payload
        {
            public int version;
            public long updatedAt;
            public Entry[] values;
            public Experiment[] experiments;
        }

        static readonly Dictionary<string, string> _values = new Dictionary<string, string>();
        static readonly Dictionary<string, string> _assignments = new Dictionary<string, string>();
        static readonly Dictionary<string, string> _properties = new Dictionary<string, string>();
        static readonly HashSet<string> _reported = new HashSet<string>();
        static bool _loaded;
        static bool _fetched;

        public static bool IsReady => _fetched;
        public static int Version { get; private set; }
        public static event Action OnFetched;

        public static bool HasValues { get { EnsureLoaded(); return _values.Count > 0; } }
        public static IReadOnlyDictionary<string, string> All { get { EnsureLoaded(); return _values; } }
        public static IReadOnlyDictionary<string, string> Assignments { get { EnsureLoaded(); return _assignments; } }

        public static string GetString(string key, string fallback = "")
        {
            EnsureLoaded();
            return _values.TryGetValue(key, out var raw) && raw != null ? raw : fallback;
        }

        public static bool GetBool(string key, bool fallback = false)
        {
            string raw = GetString(key, null);
            if (string.IsNullOrEmpty(raw)) return fallback;
            switch (raw.Trim().ToLowerInvariant())
            {
                case "true": case "1": case "yes": case "on": return true;
                case "false": case "0": case "no": case "off": return false;
                default: return fallback;
            }
        }

        public static int GetInt(string key, int fallback = 0) =>
            int.TryParse(GetString(key, null), NumberStyles.Integer, CultureInfo.InvariantCulture, out int n)
                ? n : fallback;

        public static float GetFloat(string key, float fallback = 0f) =>
            float.TryParse(GetString(key, null), NumberStyles.Float, CultureInfo.InvariantCulture, out float f)
                ? f : fallback;

        public static string GetVariant(string experimentKey)
        {
            EnsureLoaded();
            return _assignments.TryGetValue(experimentKey, out var name) ? name : null;
        }

        public static bool HasKey(string key)
        {
            EnsureLoaded();
            return _values.ContainsKey(key);
        }

        public static void Fetch(Action<bool> onDone = null)
        {
            EnsureLoaded();

            var cfg = NexusConfig.Load();
            if (cfg == null || !cfg.enableRemoteConfig)
            {
                onDone?.Invoke(false);
                return;
            }

            string url = BuildUrl(cfg);
            if (url == null)
            {
                Debug.LogWarning("[Nexus] Remote Config needs a Game ID — set it in Nexus Setup → Remote Config.");
                onDone?.Invoke(false);
                return;
            }

            var req = UnityWebRequest.Get(url);
            req.timeout = Mathf.Max(1, cfg.remoteConfigTimeoutSeconds);
            req.SetRequestHeader("Cache-Control", "no-cache");

            var op = req.SendWebRequest();
            op.completed += _ =>
            {
                bool ok = req.result == UnityWebRequest.Result.Success;
                string text = ok ? req.downloadHandler.text : null;
                string error = ok ? null : req.error;
                req.Dispose();

                if (!ok || string.IsNullOrEmpty(text) || text.Trim() == "null")
                {
                    Debug.LogWarning($"[Nexus] Remote Config fetch failed ({error ?? "empty payload"}); using cached/default values.");
                    // An offline launch still runs on cached assignments, so those players have
                    // to be counted too or the arms drift apart by connectivity.
                    ReportAssignments();
                    onDone?.Invoke(false);
                    return;
                }

                if (!Adopt(text))
                {
                    onDone?.Invoke(false);
                    return;
                }

                Debug.Log($"[Nexus] Remote Config v{Version} loaded ({_values.Count} keys, {_assignments.Count} experiments).");

                ReportAssignments();

                try { OnFetched?.Invoke(); }
                catch (Exception e) { Debug.LogWarning($"[Nexus] Remote Config listener threw: {e.Message}"); }

                onDone?.Invoke(true);
            };
        }

        // Takes on a payload fetched outside Fetch (the editor's "Fetch now"), so the wizard,
        // the Doctor and the next play-mode run all agree on what a build would see.
        public static bool Adopt(string json)
        {
            if (!ApplyPayload(json)) return false;

            PlayerPrefs.SetString(KeyPayload, json);
            PlayerPrefs.SetString(KeyFetchedAt, DateTime.UtcNow.ToString("o"));
            PlayerPrefs.Save();

            _loaded = true;
            _fetched = true;
            return true;
        }

        // Fires once per experiment+variant per session. Re-fetching re-derives the same
        // deterministic bucket, so reporting it again would only inflate the arm's population
        // and halve every per-install rate read back out of analytics.
        static void ReportAssignments()
        {
            foreach (var kv in _assignments)
            {
                if (!_reported.Add(kv.Key + "|" + kv.Value)) continue;

                DavanciNexus.Custom("remote_config_variant", ("experiment", kv.Key), ("variant", kv.Value));

                // The event counts exposures; the user property is what lets revenue and
                // retention be split by arm in GA4.
                if (_properties.TryGetValue(kv.Key, out var property) && !string.IsNullOrEmpty(property))
                    DavanciNexus.SetUserProperty(property, kv.Key + ":" + kv.Value);
            }
        }

        public static string BuildUrl(NexusConfig cfg)
        {
            string gameId = cfg == null ? null : cfg.gameId?.Trim();
            if (string.IsNullOrEmpty(gameId)) return null;

            return $"{DatabaseUrl.TrimEnd('/')}/{ConfigNode}/{Uri.EscapeDataString(gameId)}.json";
        }

        static void EnsureLoaded()
        {
            if (_loaded) return;
            _loaded = true;

            string cached = PlayerPrefs.GetString(KeyPayload, null);
            if (!string.IsNullOrEmpty(cached))
                ApplyPayload(cached);
        }

        // Returns false and leaves the working config untouched when the JSON is bad, so a
        // broken publish can't wipe live games.
        static bool ApplyPayload(string json)
        {
            Payload payload;
            try { payload = JsonUtility.FromJson<Payload>(json); }
            catch (Exception e)
            {
                Debug.LogWarning($"[Nexus] Remote Config payload is malformed: {e.Message}");
                return false;
            }

            if (payload == null) return false;

            var values = new Dictionary<string, string>();
            var assignments = new Dictionary<string, string>();
            var properties = new Dictionary<string, string>();

            Merge(values, payload.values);

            if (payload.experiments != null)
            {
                foreach (var exp in payload.experiments)
                {
                    if (exp == null || string.IsNullOrEmpty(exp.key) || exp.variants == null || exp.variants.Length == 0)
                        continue;

                    var variant = PickVariant(exp);
                    if (variant == null) continue;

                    assignments[exp.key] = variant.name;
                    properties[exp.key] = exp.property;
                    Merge(values, variant.values);
                }
            }

            _values.Clear();
            foreach (var kv in values) _values[kv.Key] = kv.Value;

            _assignments.Clear();
            foreach (var kv in assignments) _assignments[kv.Key] = kv.Value;

            _properties.Clear();
            foreach (var kv in properties) _properties[kv.Key] = kv.Value;

            Version = payload.version;
            return true;
        }

        static void Merge(Dictionary<string, string> into, Entry[] entries)
        {
            if (entries == null) return;
            foreach (var e in entries)
            {
                if (e == null || string.IsNullOrEmpty(e.k)) continue;
                into[e.k] = e.v;
            }
        }

        static Variant PickVariant(Experiment exp)
        {
            int total = 0;
            foreach (var v in exp.variants)
                if (v != null && v.weight > 0) total += v.weight;

            if (total <= 0) return null;

            int bucket = (int)(StableHash(exp.salt + "|" + exp.key + "|" + InstallId) % (uint)total);

            int cursor = 0;
            foreach (var v in exp.variants)
            {
                if (v == null || v.weight <= 0) continue;
                cursor += v.weight;
                if (bucket < cursor) return v;
            }

            return null;
        }

        // A per-install GUID, not the device id — privacy resets would re-bucket the player
        // in the middle of a running experiment.
        public static string InstallId
        {
            get
            {
                string id = PlayerPrefs.GetString(KeyInstallId, null);
                if (string.IsNullOrEmpty(id))
                {
                    id = Guid.NewGuid().ToString("N");
                    PlayerPrefs.SetString(KeyInstallId, id);
                    PlayerPrefs.Save();
                }
                return id;
            }
        }

        // FNV-1a: string.GetHashCode() is randomized per process and would re-bucket on every launch.
        static uint StableHash(string s)
        {
            unchecked
            {
                uint hash = 2166136261;
                for (int i = 0; i < s.Length; i++)
                {
                    hash ^= s[i];
                    hash *= 16777619;
                }
                return hash;
            }
        }

        public static string CachedPayload => PlayerPrefs.GetString(KeyPayload, null);

        // Schema check for tooling. Stricter than ApplyPayload on purpose: it names the
        // publishes a client would silently ignore, native JSON types above all.
        public static bool Validate(string json, out string error, out string summary)
        {
            error = null;
            summary = null;

            if (string.IsNullOrEmpty(json) || json.Trim() == "null")
            {
                error = "payload is empty";
                return false;
            }

            Payload payload;
            try { payload = JsonUtility.FromJson<Payload>(json); }
            catch (Exception e) { error = e.Message; return false; }

            if (payload == null) { error = "not a JSON object"; return false; }
            if (payload.version <= 0) { error = "\"version\" is missing or not a positive number"; return false; }

            int keys = 0;
            if (payload.values != null)
            {
                foreach (var e in payload.values)
                {
                    if (e == null || string.IsNullOrEmpty(e.k)) { error = "a values entry has no \"k\""; return false; }
                    if (e.v == null) { error = $"\"{e.k}\" has no \"v\" — every value travels as a string"; return false; }
                    keys++;
                }
            }

            int experiments = 0;
            if (payload.experiments != null)
            {
                foreach (var exp in payload.experiments)
                {
                    if (exp == null || string.IsNullOrEmpty(exp.key)) { error = "an experiment has no \"key\""; return false; }
                    if (string.IsNullOrEmpty(exp.salt)) { error = $"experiment \"{exp.key}\" has no \"salt\""; return false; }
                    if (exp.variants == null || exp.variants.Length == 0) { error = $"experiment \"{exp.key}\" has no variants"; return false; }

                    int weight = 0;
                    foreach (var v in exp.variants)
                    {
                        if (v == null || string.IsNullOrEmpty(v.name)) { error = $"a variant of \"{exp.key}\" has no \"name\""; return false; }
                        if (v.weight > 0) weight += v.weight;
                    }

                    if (weight <= 0) { error = $"experiment \"{exp.key}\" has no variant with a positive weight"; return false; }
                    experiments++;
                }
            }

            if (keys == 0 && experiments == 0) { error = "no values and no experiments"; return false; }

            summary = $"v{payload.version}, {keys} key(s), {experiments} experiment(s)";
            return true;
        }

        public static DateTime? LastFetchUtc
        {
            get
            {
                string raw = PlayerPrefs.GetString(KeyFetchedAt, null);
                return !string.IsNullOrEmpty(raw)
                    && DateTime.TryParse(raw, null, DateTimeStyles.RoundtripKind, out var when)
                    ? when
                    : (DateTime?)null;
            }
        }

        public static void ClearCache()
        {
            PlayerPrefs.DeleteKey(KeyPayload);
            PlayerPrefs.DeleteKey(KeyFetchedAt);
            PlayerPrefs.Save();
            _values.Clear();
            _assignments.Clear();
            _properties.Clear();
            _reported.Clear();
            Version = 0;
            _loaded = false;
            _fetched = false;
        }

        public static void ResetInstallId()
        {
            PlayerPrefs.DeleteKey(KeyInstallId);
            PlayerPrefs.Save();
            _loaded = false;
        }
    }
}
