using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

namespace IndieExchange
{
    internal sealed class PlacementState
    {
        public IndiePlacement placement;
        public IndiePlacementStats stats;
        public string key;
        public PreparedAd ready;
        public bool loading;
        public int failures;
        public double nextLoad;
        public bool dirty;
        public bool requested;
        public long loadOrder;
        public bool retryAfterCooldown;
        public AdResponse noFillReceipt;
        public double noFillExpires;
    }

    internal sealed class IndieAdsRunner : MonoBehaviour
    {
        private readonly Dictionary<string, PlacementState> placements = new Dictionary<string, PlacementState>();
        private readonly CancellationTokenSource lifetime = new CancellationTokenSource();
        private IndieAdsSettings settings;
        private TrackingQueue tracking;
        private string folder, installId, scope;
        private bool configurationReady, localTest;
        private string configurationError = "";
        private bool configurationFinished;
        internal string ConfigurationError => configurationFinished ? configurationError : "";
        internal string LoadError { get; set; } = "";
        private DateTimeOffset configurationExpires = DateTimeOffset.MaxValue;
        internal bool ConfigurationReady => configurationReady && DateTimeOffset.UtcNow < configurationExpires && !stopped;
        internal static readonly Dictionary<string, LaunchConfiguration> Launches = new Dictionary<string, LaunchConfiguration>();
        private bool http, stopped, paused;
        private AdView fullView, bannerView;
        private PreparedAd fullAd, bannerAd;
        private string bannerPlacement;
        private TaskCompletionSource<bool> showing;
        private bool didOpen, earnedReward;
        private Action rewardCallback;
        private bool ownsGamePause, previousAudioPause;
        private float previousTimeScale;
        private bool reporting;
        private double nextReport;
        private int loadsInFlight;
        private long loadSequence;
        private bool loadThrottled;
        public bool IsShowing => showing != null;
        internal bool IsShowingBanner => bannerView != null;
        #if UNITY_EDITOR
        internal bool TestForeground;
#endif
        private bool Foreground {
            get {
#if UNITY_EDITOR
                if (TestForeground) return true;
#endif
                return Application.isFocused || settings != null && settings.testMode && Application.isBatchMode;
            }
        }

        internal bool Configure(IndieAdsSettings source)
        {
            settings = source.Snapshot();
            var development = Application.isEditor || Debug.isDebugBuild;
            http = development && settings.allowDevelopmentHttp;
            localTest = settings.testMode && (Application.isEditor || string.IsNullOrEmpty(settings.gameId));
#if UNITY_EDITOR
            // Internal test harness can exercise device preview networking in the Editor.
            if (settings.useServerTestPreview) localTest = false;
            localTest |= settings.testMode && settings.useLocalTestPlacements;
#endif
            if (!localTest && (!AdTransport.AllowedUrl(settings.apiBaseUrl, http) || !Guid.TryParse(settings.gameId, out _)))
            { Debug.LogWarning("Indie Ads: enter the Game ID from My games."); return false; }
            if (!Application.isEditor && Application.platform != RuntimePlatform.Android && Application.platform != RuntimePlatform.IPhonePlayer && !settings.testMode)
            { Debug.LogWarning("Indie Ads: live delivery supports Android and iOS."); return false; }
            scope = Hash("v3|" + settings.apiBaseUrl + "|" + (settings.gameId.Length > 0 ? settings.gameId : settings.sdkKey) + "|" + settings.testMode);
            folder = Path.Combine(Application.persistentDataPath, "IndieExchange", scope);
            try
            {
                Directory.CreateDirectory(folder);
                // Only delete SDK-owned video files in this exact scope directory.
                foreach (var path in Directory.GetFiles(folder, "*.mp4", SearchOption.TopDirectoryOnly)) File.Delete(path);
            }
            catch { Debug.LogWarning("Indie Ads: cache folder unavailable."); return false; }
            var identityKey = "IndieExchange." + scope + ".installation";
            installId = PlayerPrefs.GetString(identityKey, "");
            if (installId.Length == 0) { installId = Guid.NewGuid().ToString("N"); PlayerPrefs.SetString(identityKey, installId); PlayerPrefs.Save(); }
            tracking = new TrackingQueue(folder, settings, lifetime.Token, installId);
            if (localTest) {
                var testPlacements = new[] {
                    new IndiePlacement { id = "interstitial", percentage = 100 },
                    new IndiePlacement { id = "banner", format = IndieAdFormat.Banner, percentage = 100 },
                    new IndiePlacement { id = "rewarded", format = IndieAdFormat.Rewarded, percentage = 100 }
                };
#if UNITY_EDITOR
                if (settings.useLocalTestPlacements) testPlacements = settings.placements;
#endif
                if (!InstallPlacements(testPlacements)) return false;
                configurationReady = true;
            } else _ = DownloadConfiguration();
            return true;
        }

        private bool InstallPlacements(IndiePlacement[] values)
        {
            if (values == null || values.Length > 32) return false;
            foreach (var placement in values)
            {
                if (placement == null || string.IsNullOrWhiteSpace(placement.id) || placement.id.Length > 128 ||
                    placements.ContainsKey(placement.id) || !Enum.IsDefined(typeof(IndieAdFormat), placement.format)) return false;
                if (placement.format == IndieAdFormat.Banner && !ValidBanner(placement.bannerSize)) return false;
                placement.percentage = Mathf.Clamp(placement.percentage, 0, 100);
                var key = "IndieExchange." + scope + "." + Hash(placement.id + "|" + placement.format);
                IndiePlacementStats stats = null;
                try { if (PlayerPrefs.HasKey(key)) stats = JsonUtility.FromJson<IndiePlacementStats>(PlayerPrefs.GetString(key)); } catch { }
                if (stats == null) stats = new IndiePlacementStats {
                    remainder = (int)(BitConverter.ToUInt32(Guid.NewGuid().ToByteArray(),0) % 100),
                    publicRemainder = (int)(BitConverter.ToUInt32(Guid.NewGuid().ToByteArray(),0) % 100)
                };
                stats.remainder = Mathf.Clamp(stats.remainder, 0, 99);
                stats.publicRemainder = Mathf.Clamp(stats.publicRemainder, 0, 99);
                var state = new PlacementState { placement = placement, stats = stats, key = key, dirty = true };
                placements.Add(placement.id, state);
                if (placement.policyRoot && placement.percentage > 0) {
                    foreach (var category in new[] { "public", "personal" }) {
                        if (category == "public" && placement.publicPercentage == 0 ||
                            category == "personal" && placement.publicPercentage == 100 && !placement.personalFallback) continue;
                        var slot = new IndiePlacement { id = placement.id + ":" + category, format = placement.format,
                            percentage = placement.percentage, category = category, bannerSize = placement.bannerSize, bannerPosition = placement.bannerPosition };
                        placements.Add(slot.id, new PlacementState { placement = slot, stats = stats, key = key });
                    }
                }
            }
            return true;
        }

        private async Task DownloadConfiguration()
        {
            try {
                configurationError = "Server settings are invalid or unavailable. Restart the app to retry.";
                LaunchConfiguration config;
                if (!Launches.TryGetValue(scope, out config)) {
                    // Cache failures as well as successes for this process launch. Scene changes,
                    // repeated Initialize, or Shutdown/Initialize must not start another fetch.
                    Launches.Add(scope, null);
                    var result = await SecureTransport.Send(settings, installId, "configuration", "{\"supportsRewarded\":true}", lifetime.Token);
                    if (!result.Ok || result.body == null || stopped) { configurationError = SecureTransport.Describe(result); return; }
                    config = JsonUtility.FromJson<LaunchConfiguration>(Encoding.UTF8.GetString(result.body));
                    Launches[scope] = config;
                }
                if (config == null || config.session == null || config.session.Length != 64 || config.formats == null || config.formats.Length != 3 ||
                    !DateTimeOffset.TryParse(config.expires, System.Globalization.CultureInfo.InvariantCulture,
                        System.Globalization.DateTimeStyles.AssumeUniversal, out var expires) || expires <= DateTimeOffset.UtcNow) return;
                var values = new List<IndiePlacement>(); var formats = new HashSet<int>();
                foreach (var item in config.formats) {
                    if (item == null || item.format < 0 || item.format > 2 || !formats.Add(item.format) ||
                        item.percentage < 0 || item.percentage > 100 || item.publicPercentage < 0 || item.publicPercentage > 100 ||
                        !ValidBanner(item.bannerSize) || item.bannerPosition != "top" && item.bannerPosition != "bottom") return;
                    values.Add(new IndiePlacement { id = item.format == 0 ? "interstitial" : item.format == 2 ? "banner" : "rewarded",
                        format = (IndieAdFormat)item.format, percentage = item.percentage, publicPercentage = item.publicPercentage, personalFallback = item.personalFallback,
                        bannerSize = item.bannerSize, bannerPosition = item.bannerPosition, policyRoot = true });
                }
                if (stopped) return;
                settings.session = config.session;
                configurationExpires = expires;
                settings.maxCachedAds = 6;
                configurationReady = InstallPlacements(values.ToArray());
                if (configurationReady) configurationError = "";
            } catch { configurationReady = false; }
            finally { configurationFinished = true; }
        }

        private PlacementState DisplaySlot(PlacementState state, bool recovery)
        {
            if (!state.placement.policyRoot) return state;
            if (settings.testMode || recovery) {
                foreach (var categoryName in new[] { "personal", "public" })
                    if (placements.TryGetValue(state.placement.id + ":" + categoryName, out var ready) && ready.ready != null && ready.ready.Ready) return ready;
                if (recovery) return null;
            }
            var counter = new PercentageCounter(state.stats.publicRemainder);
            var category = counter.Next(state.placement.publicPercentage) ? "public" : "personal";
            state.stats.publicRemainder = counter.Remainder;
            Save(state);
            placements.TryGetValue(state.placement.id + ":" + category, out var slot);
            // A server-confirmed no-fill earns only when this normal public opportunity is consumed.
            // Loading, test ads, personal slots and provider recovery calls never consume receipts.
            if (category == "public" && !settings.testMode && !recovery &&
                (slot == null || slot.ready == null || !slot.ready.Ready)) {
                var receipt = ConsumeNoFill(state, Time.realtimeSinceStartupAsDouble);
                if (receipt != null) tracking.Add("opportunity", receipt.requestId, receipt.ticket);
            }
            // Keep the scheduled split, but fill an unavailable public opportunity with
            // an eligible cached personal ad when the launch settings permit it.
            if (category == "public" && state.placement.personalFallback &&
                (slot == null || slot.ready == null || !slot.ready.Ready) &&
                placements.TryGetValue(state.placement.id + ":personal", out var personal) &&
                personal.ready != null && personal.ready.Ready) return personal;
            return slot;
        }

        internal static AdResponse ConsumeNoFill(PlacementState state, double now)
        {
            var receipt = state.noFillReceipt;
            state.noFillReceipt = null;
            return now < state.noFillExpires ? receipt : null;
        }

        internal void RememberNoFill(IndiePlacement placement, AdResponse response)
        {
            if (settings.testMode || stopped || placement.category != "public" ||
                !placements.TryGetValue(placement.id.Split(':')[0], out var root)) return;
            root.noFillReceipt = null;
            // JsonUtility can materialize a JSON null inline object as an empty AdCreative.
            if (response == null || !response.isNoFillOpportunity || response.isPreview ||
                response.creative != null && !string.IsNullOrEmpty(response.creative.url) ||
                string.IsNullOrEmpty(response.requestId) || string.IsNullOrEmpty(response.ticket) || response.ticket.Length != 64 ||
                !DateTimeOffset.TryParse(response.ticketExpires, System.Globalization.CultureInfo.InvariantCulture,
                    System.Globalization.DateTimeStyles.AssumeUniversal, out var expiry)) return;
            var seconds = (expiry - DateTimeOffset.UtcNow).TotalSeconds - 45;
            if (seconds <= 0) return;
            root.noFillReceipt = response;
            root.noFillExpires = Time.realtimeSinceStartupAsDouble + Math.Min(255, seconds);
        }

        private static string Hash(string value)
        {
            using (var sha = SHA256.Create()) return BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(value))).Replace("-", "").Substring(0, 24);
        }
        private static bool ValidBanner(string value) => value == "320x50" || value == "320x100" || value == "300x250" || value == "728x90";

        private void Update()
        {
            if (stopped || paused || !Foreground || !ConfigurationReady) return;
            foreach (var state in placements.Values)
            {
                if (state.ready != null && !state.ready.Ready) { state.ready.Dispose(); state.ready = null; }
            }
            PumpLoads();
            if (!settings.testMode) _ = tracking.Tick();
            if (!settings.testMode && !reporting && Time.realtimeSinceStartupAsDouble >= nextReport) _ = ReportStatistics();
        }

        private void PumpLoads()
        {
            if (!ConfigurationReady || stopped || paused || !Foreground || loadsInFlight > 0 || showing != null) return;
            var cached = 0;
            foreach (var state in placements.Values) if (state.ready != null && state.ready.Ready) cached++;
            PlacementState next = null;
            foreach (var state in placements.Values) {
                if (state.placement.policyRoot || state.loading ||
                    state.ready != null && state.ready.Ready || Time.realtimeSinceStartupAsDouble < state.nextLoad ||
                    !state.requested && (state.placement.percentage == 0 || cached >= settings.maxCachedAds) ||
                    bannerView != null && bannerPlacement == state.placement.id) continue;
                // A throttled category keeps its turn. No-fill consumes a turn, so the
                // other category can use the next server cooldown window.
                var priority = state.retryAfterCooldown ? 2 : state.requested ? 1 : 0;
                var nextPriority = next == null ? -1 : next.retryAfterCooldown ? 2 : next.requested ? 1 : 0;
                if (next == null || priority > nextPriority || priority == nextPriority && state.loadOrder < next.loadOrder) next = state;
            }
            if (next != null) _ = Load(next);
        }

        private async Task Load(PlacementState state)
        {
            if (!ConfigurationReady || state.placement.policyRoot || state.loading ||
                state.ready != null && state.ready.Ready || Time.realtimeSinceStartupAsDouble < state.nextLoad) return;
            if (loadsInFlight > 0 || showing != null) return;
            var cached = 0;
            PlacementState oldest = null;
            foreach (var entry in placements.Values)
                if (entry.ready != null)
                {
                    cached++;
                    if (oldest == null || entry.ready.expires < oldest.ready.expires) oldest = entry;
                }
            if (cached >= settings.maxCachedAds)
            {
                if (!state.requested) return;
                oldest.ready.Dispose(); oldest.ready = null;
            }
            state.requested = false;
            loadsInFlight++;
            state.loading = true;
            loadThrottled = false;
            try
            {
                var ad = localTest ? PreparedAd.Test(state.placement, settings.cacheMinutes) :
                    await PreparedAd.Load(this, state.placement, settings, folder, installId, http, lifetime.Token);
                if (stopped) { ad?.Dispose(); return; }
                state.ready?.Dispose();
                state.ready = ad;
                if (!loadThrottled) {
                    state.failures = ad != null ? 0 : Math.Min(6, state.failures + 1);
                    state.nextLoad = Math.Max(state.nextLoad, Time.realtimeSinceStartupAsDouble + (ad != null ? 1 : Math.Min(120, 5 * Math.Pow(2, state.failures - 1))));
                }
            }
            catch { state.nextLoad = Time.realtimeSinceStartupAsDouble + 30; }
            finally {
                state.retryAfterCooldown = loadThrottled;
                if (!loadThrottled) state.loadOrder = ++loadSequence;
                state.loading = false; loadsInFlight--;
            }
        }

        internal void DeferLoads(IndieAdFormat format, int seconds)
        {
            loadThrottled = true;
            var until = Time.realtimeSinceStartupAsDouble + Math.Max(1, Math.Min(3600, seconds));
            foreach (var state in placements.Values)
                if (state.placement.format == format) state.nextLoad = Math.Max(state.nextLoad, until);
        }

        internal bool IsReady(string id)
        {
            if (!ConfigurationReady || id == null || !placements.TryGetValue(id, out var state)) return false;
            if (state.placement.policyRoot) return IsReady(id + ":public") || IsReady(id + ":personal");
            return state.ready != null && state.ready.Ready;
        }
        internal void Preload(string id)
        {
            if (ConfigurationReady && id != null && placements.TryGetValue(id, out var state))
            {
                if (state.placement.policyRoot) { Preload(id + ":public"); Preload(id + ":personal"); return; }
                if (state.ready != null && state.ready.Ready) return;
                state.requested = true;
                PumpLoads();
            }
        }
        internal IndiePlacementStats GetStatistics(string id) => id != null && placements.TryGetValue(id, out var s) ? s.stats.Copy() : new IndiePlacementStats();

        private bool Select(PlacementState state, bool bypass)
        {
            // Recovery is outside normal allocation; 0% remains the live server off switch.
            if (bypass) return settings.testMode || state.placement.percentage > 0;
            state.stats.opportunities++;
            var counter = new PercentageCounter(state.stats.remainder);
            var selected = counter.Next(state.placement.percentage);
            state.stats.remainder = counter.Remainder;
            if (selected) state.stats.selected++;
            Save(state);
            return selected;
        }
        private void Save(PlacementState state)
        {
            state.dirty = true;
            PlayerPrefs.SetString(state.key, JsonUtility.ToJson(state.stats));
            PlayerPrefs.Save();
            var id = state.placement.category == null ? state.placement.id : state.placement.id.Split(':')[0];
            if (placements.TryGetValue(id, out var root)) root.dirty = true;
            IndieAds.Stats(id, state.stats);
        }
        private async Task ReportStatistics()
        {
            reporting = true;
            nextReport = Time.realtimeSinceStartupAsDouble + 600; // Attested diagnostics must not dominate the device's integrity quota.
            try
            {
                foreach (var state in placements.Values)
                {
                    if (state.placement.category != null || !state.dirty || state.stats.opportunities == 0 && state.stats.shown == 0) continue;
                    state.dirty = false;
                    var stats = state.stats.Copy();
                    var body = new StatisticsReport { sdkKey = settings.gameId, installationId = installId,
                        placementId = state.placement.id, format = (int)state.placement.format,
                        opportunities = stats.opportunities, selected = stats.selected, shown = stats.shown,
                        unavailable = stats.unavailable, fallbackShown = stats.fallbackShown };
                    var result = await SecureTransport.Send(settings, installId, "placement-stats", JsonUtility.ToJson(body), lifetime.Token);
                    if (!result.Ok) state.dirty = true;
                    if (stopped) return;
                    break; // One small snapshot per interval; never block rendering.
                }
            }
            finally { reporting = false; }
        }
        private bool Eligible(string id, IndieAdFormat format, out PlacementState state)
        {
            state = null;
            return ConfigurationReady && !paused && Foreground && id != null &&
                placements.TryGetValue(id, out state) && state.placement.format == format && state.placement.category == null;
        }

        internal Task<bool> TryShow(string id, bool bypass, IndieAdFormat format = IndieAdFormat.Interstitial, Action onRewardEarned = null)
        {
            // Coalesce a second call while a full-screen ad is active. Never launch the host fallback over it.
            if (showing != null) return showing.Task;
            if (!Eligible(id, format, out var state) || !Select(state, bypass)) return Task.FromResult(false);
            var root = state;
            state = DisplaySlot(root, bypass);
            if (state == null || !IsReady(state.placement.id))
            {
                if (!bypass) { root.stats.unavailable++; Save(root); }
                return Task.FromResult(false);
            }
            HideBanner();
            var ad = state.ready; state.ready = null; fullAd = ad;
            showing = new TaskCompletionSource<bool>();
            var result = showing.Task;
            didOpen = false; earnedReward = false; rewardCallback = onRewardEarned;
            try
            {
                previousTimeScale = Time.timeScale;
                previousAudioPause = AudioListener.pause;
                ownsGamePause = true;
                Time.timeScale = 0;
                AudioListener.pause = true;
                fullView = AdView.Create(transform, ad, state.placement,
                    () =>
                    {
                        didOpen = true;
                        RecordShown(state, ad, bypass);
                        IndieAds.Opened(id);
                    },
                    () => Track("click", ad),
                    () => Finish(id),
                    () => { if (earnedReward || showing == null) return; earnedReward = true; Track("rewarded-complete", ad); });
                // A fully cached creative is attached to the active overlay now.
                // Mark the outcome handled even if closed before next-frame impression reporting.
                didOpen = true;
            }
            catch
            {
                state.stats.unavailable++; Save(state);
                Finish(id);
            }
            return result;
        }

        private void Finish(string id)
        {
            if (showing == null) return;
            var completion = showing; var opened = didOpen;
            var reward = earnedReward ? rewardCallback : null;
            earnedReward = false; rewardCallback = null;
            fullView = null; fullAd?.Dispose(); fullAd = null;
            showing = null; didOpen = false;
            RestoreGame();
            if (opened) IndieAds.Closed(id);
            try { reward?.Invoke(); } catch (Exception ex) { Debug.LogException(ex); }
            completion.TrySetResult(opened);
            if (placements.TryGetValue(id, out var state)) state.nextLoad = Time.realtimeSinceStartupAsDouble + 1;
        }

        private void RestoreGame()
        {
            if (!ownsGamePause) return;
            ownsGamePause = false;
            Time.timeScale = previousTimeScale;
            AudioListener.pause = previousAudioPause;
        }

        internal bool TryBanner(string id, bool bypass)
        {
            if (showing != null) return false;
            if (bannerView != null)
            {
                if (bannerPlacement == id) return true;
                HideBanner();
            }
            if (!Eligible(id, IndieAdFormat.Banner, out var state) || !Select(state, bypass)) return false;
            var root = state;
            state = DisplaySlot(root, bypass);
            if (state == null || !IsReady(state.placement.id)) { if (!bypass) { root.stats.unavailable++; Save(root); } return false; }
            var ad = state.ready; state.ready = null;
            bannerAd = ad; bannerPlacement = id;
            try
            {
                bannerView = AdView.Create(transform, ad, state.placement,
                    () => RecordShown(state, ad, bypass), () => Track("click", ad), () =>
                    { bannerView = null; bannerAd?.Dispose(); bannerAd = null; bannerPlacement = null; });
                return true;
            }
            catch { ad.Dispose(); bannerAd = null; bannerPlacement = null; return false; }
        }
        internal void HideBanner()
        {
            if (bannerView != null) bannerView.Close();
            bannerView = null;
        }
        private void RecordShown(PlacementState state, PreparedAd ad, bool bypass)
        {
            state.stats.shown++;
            if (bypass) state.stats.fallbackShown++;
            Save(state);
            Track("impression", ad);
        }
        private void Track(string route, PreparedAd ad)
        {
            if (!ad.test && !stopped) tracking.Add(route, ad.response.requestId, ad.response.ticket);
        }
        private void OnApplicationPause(bool value) { paused = value; }
        internal void Stop()
        {
            if (stopped) return;
            stopped = true;
            lifetime.Cancel();
            if (fullView != null) fullView.Close();
            RestoreGame();
            if (showing != null) { showing.TrySetResult(didOpen); showing = null; }
            fullAd?.Dispose(); fullAd = null;
            HideBanner();
            foreach (var state in placements.Values) { state.ready?.Dispose(); state.ready = null; }
            if (settings != null) Destroy(settings);
        }
        private void OnDestroy() { Stop(); if (IndieAds.Runner == this) IndieAds.Runner = null; }
    }
}
