using Newtonsoft.Json;
using Oxide.Core.Libraries.Covalence;
using Oxide.Core.Plugins;
using Oxide.Core;
using System.Collections.Generic;
using System;
using System.Collections;
using Facepunch;
using UnityEngine;

namespace Oxide.Plugins;

[Info("Kill Heli Vote", "HunterZ/Snaplatack", "2.0.2")]
[Description("Players can vote to kill all Patrol Helicopters")]
public class KillHeliVote : RustPlugin
{
  [PluginReference]
  private readonly Plugin HeliSignals, LootDefender;

  private readonly HashSet<string> _bannedPlayers = new();
  private readonly HashSet<string> _eligiblePlayers = new();
  private readonly HashSet<string> _votedPlayers = new();
  private readonly HashSet<PatrolHelicopter> _heliCache = new();
  private Timer _msgTimer;
  private string _adminPerm;  // backing for AdminPerm
  private string _bannedPerm; // backing for BannedPerm
  private string _votePerm;   // backing for VotePerm
  private Coroutine _broadcastCoroutine;

  #region Setup

  private void Init()
  {
    Unsubscribe(nameof(OnEntityTakeDamage));
    Unsubscribe(nameof(OnLockedEntity));
    Unsubscribe(nameof(OnUnlockedEntity));
  }

  private void OnServerInitialized()
  {
    // setup hooks and commands
    AddCovalenceCommand(
      _config.CmdPerms.KillCommand, nameof(HeliKillCmd), AdminPerm);
    AddCovalenceCommand(
      _config.CmdPerms.VoteCommand, nameof(HeliVoteCmd), VotePerm);
    permission.RegisterPermission(BannedPerm, this);
    if (_config.Settings.ResetVoteOnDamage)
    {
      Subscribe(nameof(OnEntityTakeDamage));
    }
    if (_config.Settings.IgnoreLootDefender)
    {
      Subscribe(nameof(OnLockedEntity));
      Subscribe(nameof(OnUnlockedEntity));
    }

    // populate _bannedPlayers and _eligiblePlayers for all connected players
    foreach (var player in BasePlayer.activePlayerList)
    {
      UpdatePlayer(GetID(player));
    }

    // check for active helis
    // TODO: this should maybe be a coroutine
    foreach (var entity in BaseNetworkable.serverEntities)
    {
      if (entity is PatrolHelicopter heli && !ShouldIgnore(heli))
      {
        _heliCache.Add(heli);
      }
    }

    CheckStartAnnounce();
  }

  private void Unload()
  {
    StopBroadcast();
    _bannedPlayers.Clear();
    _eligiblePlayers.Clear();
    _votedPlayers.Clear();
    _heliCache.Clear();
    DestroyTimer(_msgTimer);
    _adminPerm = _bannedPerm = _votePerm = null;
  }

  #endregion

  #region Hooks

  private void OnEntitySpawned(PatrolHelicopter heli)
  {
    if (!heli || ShouldIgnore(heli)) return;

    _heliCache.Add(heli);

    CheckStartAnnounce();
  }

  private object OnEntityTakeDamage(PatrolHelicopter heli, HitInfo hitInfo)
  {
    // abort if heli invalid, no votes recorded, or not a tracked heli
    if (!heli || _votedPlayers.Count == 0 || !_heliCache.Contains(heli))
    {
      return null;
    }

    // prevent invalid or banned players from resetting votes
    var player = hitInfo.InitiatorPlayer;
    if (!player || _bannedPlayers.Contains(GetID(player))) return null;

    // NOTE: we don't need to check if damage reset is enabled in config,
    //  because the hook only gets executed if the option is enabled
    _votedPlayers.Clear();
    SendGlobalMsg(MsgKeys.HeliAttacked);
    return null;
  }

  private void OnEntityKill(PatrolHelicopter heli)
  {
    // NOTE: this is also called by OnLockedEntity(), so it should only do
    //  cleanup stuff for now
    if (!heli) return;

    _heliCache.Remove(heli);
    if (_heliCache.Count > 0) return;

    // that was the only heli, so clear votes and stop announcing
    _votedPlayers.Clear();
    DestroyTimer(_msgTimer);
  }

  private void OnPlayerConnected(BasePlayer player) =>
    UpdatePlayer(GetID(player));

  private void OnPlayerDisconnected(BasePlayer player, string reason)
  {
    if (!player) return;

    var playerID = GetID(player);
    if (string.IsNullOrEmpty(playerID)) return;

    _bannedPlayers.Remove(playerID);
    _eligiblePlayers.Remove(playerID);
    _votedPlayers.Remove(playerID);

    // check if player's removal should result in a heli kill
    CheckKillHelis();
  }

  private void OnGroupPermissionGranted(string group, string perm) =>
    OnGroupPermissionRevoked(group, perm);

  private void OnGroupPermissionRevoked(string group, string perm)
  {
    // abort if not a voting-related permission
    if (BannedPerm != perm && VotePerm != perm) return;

    // loop over all players, because we don't know who all got added/removed
    foreach (var player in BasePlayer.activePlayerList)
    {
      UpdatePlayer(GetID(player));
    }

    // invalidate any votes that were affected by permissions change
    RemoveDefunctVotes();

    // check for heli kill in case this pushed things over voting threshold
    CheckKillHelis();
  }

  private void OnUserPermissionGranted(string playerID, string perm) =>
    OnUserPermissionRevoked(playerID, perm);

  private void OnUserPermissionRevoked(string playerID, string perm)
  {
    // abort if not a voting-related permission
    if (BannedPerm != perm && VotePerm != perm) return;

    // abort if no valid playerID
    if (string.IsNullOrEmpty(playerID)) return;

    var oldEligibleCount = _eligiblePlayers.Count;

    // reevaluate kill conditions if non-voting player lost eligibility, as the
    //  voting pool shrinkage could push voting over a new threshold
    if (!UpdatePlayer(playerID) &&
        !_votedPlayers.Remove(playerID) &&
        _eligiblePlayers.Count != oldEligibleCount)
    {
      CheckKillHelis();
    }
  }

  #endregion

  #region Commands

  private void HeliKillCmd(IPlayer iPlayer, string command, string[] args) =>
    KillAllHelis();

  private void HeliVoteCmd(IPlayer iPlayer, string command, string[] args)
  {
    if (iPlayer == null || iPlayer.IsServer) return;

    var playerID = GetID(iPlayer);
    if (string.IsNullOrEmpty(playerID)) return;

    // banned players can't vote
    if (_bannedPlayers.Contains(playerID))
    {
      SendPlayerMsg(iPlayer, MsgKeys.PlayerBanned);
      return;
    }

    // can't vote when no heli active
    if (_heliCache.Count == 0)
    {
      SendPlayerMsg(iPlayer, MsgKeys.HeliNotActive);
      return;
    }

    // can't vote if already voted
    if (!_votedPlayers.Add(GetID(iPlayer)))
    {
      SendPlayerMsg(iPlayer, MsgKeys.PlayerAlreadyVoted,
        _votedPlayers.Count, NumVotesRequired(),
        _config.CmdPerms.VoteCommand);
      return;
    }

    // if this vote results in a heli kill, we're done
    if (CheckKillHelis()) return;

    // provide voting feedback to player
    SendPlayerMsg(iPlayer, MsgKeys.PlayerVoted,
      _votedPlayers.Count, NumVotesRequired(),
      _config.CmdPerms.VoteCommand);
  }

  #endregion

  #region Plugin Integrations

  private bool ShouldIgnore(PatrolHelicopter heli) =>
    !heli
    ||
    (_config.Settings.IgnoreHeliSignal &&
     HeliSignals?.CallHook("IsHeliSignalObject", heli.skinID) != null)
    ||
    (_config.Settings.IgnoreLootDefender &&
     LootDefender?.CallHook("IsDefended", heli) is true);

  private void OnLockedEntity(PatrolHelicopter heli, ulong _, ulong __)
  {
    // don't need to check anything else here, as this hook will only be called
    //  if we care about it
    if (heli) OnEntityKill(heli);
  }

  private void OnUnlockedEntity(PatrolHelicopter heli, ulong _, ulong __)
  {
    // don't need to check anything else here, as this hook will only be called
    //  if we care about it
    if (heli) OnEntitySpawned(heli);
  }

  #endregion

  #region Methods

  private static void DestroyTimer(Timer t)
  {
    if (TimerValid(t)) t.Destroy();
  }

  private static bool TimerValid(Timer t) => false == t?.Destroyed;

  private bool CanUse(string userIDString) =>
    permission.UserHasPermission(userIDString, VotePerm);

  private bool IsBanned(string userIDString) =>
    permission.UserHasPermission(userIDString, BannedPerm);

  // update _bannedPlayers and _eligiblePlayers for the given player's ID
  // returns whether player is eligible for voting
  // NOTE: do NOT update _votedPlayers from here, for various reasons!
  private bool UpdatePlayer(string userIDString)
  {
    if (string.IsNullOrEmpty(userIDString)) return false;

    if (IsBanned(userIDString))
    {
      _bannedPlayers.Add(userIDString);
      _eligiblePlayers.Remove(userIDString);
      return false;
    }
    _bannedPlayers.Remove(userIDString);

    if (CanUse(userIDString))
    {
      _eligiblePlayers.Add(userIDString);
      return true;
    }

    return false;
  }

  private static string GetID(BasePlayer player) => player?.UserIDString;

  private static string GetID(IPlayer iPlayer) => iPlayer?.Id;

  private int NumVotesRequired()
  {
    var votesRequired = (int)Mathf.Ceil(
      _eligiblePlayers.Count *
      (_config.Settings.PercentVotesRequired / 100.0f));
    if (votesRequired <= 0) votesRequired = 1;
    return votesRequired;
  }

  // delay 5 seconds to give vanilla spawn toast time to end
  private void CheckStartAnnounce() => timer.Once(5.0f, () =>
  {
    // abort if no helis or already announcing
    if (_heliCache.Count == 0 || TimerValid(_msgTimer)) return;
    Announce();
    _msgTimer = timer.Every(_config.Settings.AnnounceTime, Announce);
  });

  private void Announce()
  {
    SendGlobalMsg(MsgKeys.HeliAnnouncement,
      _votedPlayers.Count, NumVotesRequired(),
      _config.CmdPerms.VoteCommand);
  }

  // check whether helis should be killed, and kill them if so
  // returns whether helis were killed
  private bool CheckKillHelis()
  {
    // abort if no voting-eligible players, no kill-eligible helis, or no votes
    if (_eligiblePlayers.Count == 0 ||
        _heliCache.Count == 0 ||
        _votedPlayers.Count == 0)
    {
      return false;
    }
    // abort if votes do not meet/exceed threshold
    if (_votedPlayers.Count < NumVotesRequired()) return false;

    KillAllHelis();
    return true;
  }

  private void KillAllHelis()
  {
    if (_heliCache.Count == 0) return;

    SendGlobalMsg(MsgKeys.HeliKilled, _heliCache.Count);

    // copy the cache to avoid modify-on-iterate issues
    var killList = Pool.Get<List<PatrolHelicopter>>();
    killList.Capacity = _heliCache.Count;
    foreach (var heli in _heliCache)
    {
      killList.Add(heli);
    }
    // now kill each heli in the list
    foreach (var heli in killList)
    {
      // NOTE: this will result in immediate OnEntityKill() callbacks, so we
      //  don't need to manually remove from _heliCache here
      heli.Kill();
    }
    Pool.FreeUnmanaged(ref killList);

    DestroyTimer(_msgTimer);
    _votedPlayers.Clear();
  }

  private string AdminPerm
  {
    get
    {
      _adminPerm ??= $"{Name}.{_config.CmdPerms.AdminPerm}";
      return _adminPerm;
    }
  }

  private string BannedPerm
  {
    get
    {
      _bannedPerm ??= $"{Name}.{_config.CmdPerms.BannedPerm}";
      return _bannedPerm;
    }
  }

  private string VotePerm
  {
    get
    {
      _votePerm ??= $"{Name}.{_config.CmdPerms.VotePerm}";
      return _votePerm;
    }
  }

  private bool IsOnline(string playerID) =>
    true == covalence.Players.FindPlayerById(playerID)?.IsConnected;

  // remove any defunct votes from _votedPlayers based on current _bannedPlayers
  //  and _eligiblePlayers data
  private void RemoveDefunctVotes()
  {
    var defunctVotes = Pool.Get<List<string>>();
    // scan vote list for players who are offline or are ineligible for voting
    foreach (var playerID in _votedPlayers)
    {
      if (!IsOnline(playerID))
      {
        _bannedPlayers.Remove(playerID);
        _eligiblePlayers.Remove(playerID);
        defunctVotes.Add(playerID);
        continue;
      }
      if (!_eligiblePlayers.Contains(playerID))
      {
        defunctVotes.Add(playerID);
      }
    }
    // now remove defunct players from voting list
    if (defunctVotes.Count > 0)
    {
      Puts($"Purging {defunctVotes.Count} defunct vote(s)");
      foreach (var defunctVote in defunctVotes)
      {
        _votedPlayers.Remove(defunctVote);
      }
    }
    Pool.FreeUnmanaged(ref defunctVotes);
  }

  #endregion

  #region Messaging

  private enum MsgKeys
  {
    HeliAnnouncement,
    HeliAttacked,
    HeliKilled,
    HeliNotActive,
    PlayerAlreadyVoted,
    PlayerBanned,
    PlayerVoted
  }

  protected override void LoadDefaultMessages()
  {
    lang.RegisterMessages(new Dictionary<string, string>
    {
      {
        nameof(MsgKeys.HeliAnnouncement),
        "A Patrol Helicopter is active!\nCast your vote with <color=#87a3ff>/{2}</color> to kill it!\n{0}/{1} needed players have voted!"
      },
      {
        nameof(MsgKeys.HeliAttacked),
        "Someone has attacked a Patrol Helicopter!\nVoting has been reset!"
      },
      {
        nameof(MsgKeys.HeliKilled),
        "{0} Patrol Helicopter(s) KILLED!!"
      },
      {
        nameof(MsgKeys.HeliNotActive),
        "No killable Patrol Helicopter active!"
      },
      {
        nameof(MsgKeys.PlayerAlreadyVoted),
        "You have already voted!\n{0}/{1} needed players have voted!"
      },
      {
        nameof(MsgKeys.PlayerBanned),
        "You are not allowed to vote!"
      },
      {
        nameof(MsgKeys.PlayerVoted),
        "Vote recorded!\n{0}/{1} needed players have voted!"
      }
    }, this);
  }

  private void UpdateMessageConfig()
  {
    // remove any obsolete messages from config
    var deadKeys = Pool.Get<List<string>>();
    foreach (var cfgKey in _config.MsgSettings.Messages.Keys)
    {
      if (!Enum.TryParse(cfgKey, out MsgKeys _))
      {
        deadKeys.Add(cfgKey);
      }
    }
    foreach (var deadKey in deadKeys)
    {
      _config.MsgSettings.Messages.Remove(deadKey);
    }
    Pool.FreeUnmanaged(ref deadKeys);
    // ensure all current messages are in config
    foreach (MsgKeys key in Enum.GetValues(typeof(MsgKeys)))
    {
      var msgName = GetMessageName(key);
      if (!_config.MsgSettings.Messages.ContainsKey(msgName))
      {
        _config.MsgSettings.Messages.Add(msgName, GetDefaultVars(key));
      }
    }
  }

  private void SendPlayerMsg(
    IPlayer iPlayer, MsgKeys msgKey, params object[] formatParams)
  {
    if (null == iPlayer) return;

    var msgName = GetMessageName(msgKey);
    if (!_config.MsgSettings.Messages.TryGetValue(msgName, out var settings))
    {
      return;
    }
    var message = GetMessage(msgName, null, formatParams);

    if (iPlayer.IsServer)
    {
      Puts(message);
      return;
    }

    var player = iPlayer.Object as BasePlayer;
    if (!player) return;

    if (settings.UseChat)
    {
      Player.Message(player, message, _config.MsgSettings.ChatMsgID);
    }

    if (!settings.UseToast) return;
    // player.ShowToast((GameTip.Styles)settings.Type, message);
    player.SendConsoleCommand(
      "gametip.showtoast", settings.Type, message, string.Empty, false);
  }

  private IEnumerator BroadcastRoutine(
    MsgKeys msgKey, params object[] formatParams)
  {
    var msgName = GetMessageName(msgKey);
    if (!_config.MsgSettings.Messages.TryGetValue(msgName, out var settings))
    {
      yield break;
    }
    var message = GetMessage(msgName, null, formatParams);

    if (settings.UseChat)
    {
      Server.Broadcast(message, _config.MsgSettings.ChatMsgID);
    }

    if (!settings.UseToast) yield break;

    // use a temporary dict to cache the formatted message for each language
    //  encountered, so that we only have to retrieve and format it once
    var msgDict = Pool.Get<Dictionary<string, string>>();
    // seed dictionary with server's default language, since we already
    //  formatted a message for that
    msgDict[lang.GetLanguage(null)] = message;
    foreach (var player in BasePlayer.activePlayerList)
    {
      // do the shortest coroutine yield possible
      yield return null;

      if (!player) continue;
      var playerID = GetID(player);
      if (string.IsNullOrEmpty(playerID) || _bannedPlayers.Contains(playerID))
      {
        continue;
      }
      var playerLang = lang.GetLanguage(playerID);
      if (!msgDict.TryGetValue(playerLang, out var playerMsg))
      {
        playerMsg = GetMessage(msgName, playerID, formatParams);
        msgDict.Add(playerLang, playerMsg);
      }
      // player.ShowToast((GameTip.Styles)settings.Type, playerMsg);
      player.SendConsoleCommand(
        "gametip.showtoast", settings.Type, playerMsg, string.Empty, false);
    }
    Pool.FreeUnmanaged(ref msgDict);
    _broadcastCoroutine = null;
  }

  private void StopBroadcast()
  {
    if (null == _broadcastCoroutine) return;
    PrintWarning("Interrupting in-progress broadcast");
    ServerMgr.Instance.StopCoroutine(_broadcastCoroutine);
    _broadcastCoroutine = null;
  }

  private void SendGlobalMsg(MsgKeys msgKey, params object[] formatParams)
  {
    StopBroadcast();
    _broadcastCoroutine =
      ServerMgr.Instance.StartCoroutine(BroadcastRoutine(msgKey, formatParams));
  }

  private string GetMessage(
    string msgName, string playerId = null, params object[] formatParams) =>
    string.IsNullOrEmpty(msgName) ?
      msgName :
      string.Format(lang.GetMessage(msgName, this, playerId), formatParams);

  private static string GetMessageName(MsgKeys msgKey) =>
    Enum.GetName(typeof(MsgKeys), msgKey) ?? string.Empty;

  private static MessageVars GetDefaultVars(MsgKeys msgKey) =>
    msgKey switch
    {
      MsgKeys.HeliAnnouncement =>
        new MessageVars { UseChat = true, UseToast = false, Type = 2 },
      MsgKeys.HeliAttacked =>
        new MessageVars { UseChat = true, UseToast = false, Type = 4 },
      MsgKeys.HeliKilled =>
        new MessageVars { UseChat = true, UseToast = false, Type = 4 },
      MsgKeys.HeliNotActive =>
        new MessageVars { UseChat = true, UseToast = false, Type = 5 },
      MsgKeys.PlayerAlreadyVoted =>
        new MessageVars { UseChat = true, UseToast = false, Type = 3 },
      MsgKeys.PlayerBanned =>
        new MessageVars { UseChat = true, UseToast = false, Type = 5 },
      MsgKeys.PlayerVoted =>
        new MessageVars { UseChat = true, UseToast = false, Type = 3 },
      _ => new MessageVars()
    };

  #endregion

  #region Configuration

  private Configuration _config;

  private sealed class Configuration
  {
    [JsonProperty(PropertyName = "Plugin Settings")]
    public GeneralSettings Settings = new()
    {
      AnnounceTime = 150,
      PercentVotesRequired = 80,
      IgnoreHeliSignal = false,
      IgnoreLootDefender = false
    };

    [JsonProperty(PropertyName = "Commands & Permissions")]
    public CmdPerms CmdPerms = new()
    {
      VoteCommand = "voteheli",
      KillCommand = "killhelis",
      VotePerm = "use",
      BannedPerm = "banned",
      AdminPerm = "admin"
    };

    [JsonProperty(PropertyName = "Messages")]
    public MessageSettings MsgSettings = new()
    {
      ChatMsgID = 0,
      Messages = new SortedDictionary<string, MessageVars>()
    };

    public VersionNumber Version = new(0, 0, 0);
  }

  protected override void LoadDefaultConfig()
  {
    Puts("Creating a new config file");
    _config = new Configuration
    {
      Version = Version
    };
    UpdateMessageConfig();
    // NOTE: Oxide will call this followed by SaveConfig() if there's no config
    //  file, so don't call SaveConfig() from here
  }

  protected override void LoadConfig()
  {
    var saveConfig = false;
    try
    {
      base.LoadConfig();
      _config = Config.ReadObject<Configuration>();
      if (_config == null)
      {
        Puts("Config is null; loading default");
        LoadDefaultConfig();
        saveConfig = true;
      }
      else if (_config?.Version != Version)
      {
        UpdateConfig();
        saveConfig = true;
      }
    }
    catch (Exception exception)
    {
      PrintWarning($"Exception reading config file: {exception}");
      LoadDefaultConfig();
      saveConfig = true;
    }

    if (saveConfig)
    {
      SaveConfig();
    }
  }

  protected override void SaveConfig()
  {
    Puts($"Configuration changes saved to {Name}.json");
    Config.WriteObject(_config, true);
  }

  private void UpdateConfig()
  {
    if (_config.Version >= Version) return;
    if (_config.Version < new VersionNumber(2, 0, 0))
    {
      PrintWarning($"Old {_config.Version} config file will be overwritten due to being incompatible");
      LoadDefaultConfig();
    }

    UpdateMessageConfig();
    _config.Version = Version;

    PrintWarning($"[CONFIG UPDATE] Updated to Version {Version}");
  }

  #endregion

  #region Fields

  private sealed class GeneralSettings
  {
    [JsonProperty(PropertyName = "Frequency of vote announcements [in seconds]")]
    public float AnnounceTime;
    [JsonProperty(PropertyName = "Percentage of votes required [0 - 100]")]
    public float PercentVotesRequired;
    [JsonProperty(PropertyName = "Ignore helis spawned by Heli Signals")]
    public bool IgnoreHeliSignal;
    [JsonProperty(PropertyName = "Ignore helis locked by Loot Defender")]
    public bool IgnoreLootDefender;
    [JsonProperty(PropertyName = "Reset vote when a heli takes damage")]
    public bool ResetVoteOnDamage;
  }

  private sealed class CmdPerms
  {
    [JsonProperty(PropertyName = "Heli vote command")]
    public string VoteCommand;
    [JsonProperty(PropertyName = "Heli kill command [Requires admin perm]")]
    public string KillCommand;
    [JsonProperty(PropertyName = "Voting permission")]
    public string VotePerm;
    [JsonProperty(PropertyName = "Banned permission")]
    public string BannedPerm;
    [JsonProperty(PropertyName = "Admin Permission")]
    public string AdminPerm;
  }

  private sealed class MessageSettings
  {
    [JsonProperty(PropertyName = "Steam ID to use for the image for messages [0 = default]")]
    public ulong ChatMsgID;
    [JsonProperty(PropertyName = "Messages")]
    public SortedDictionary<string, MessageVars> Messages;
  }

  private sealed class MessageVars
  {
    [JsonProperty(PropertyName = "Use chat messages")]
    public bool UseChat;
    [JsonProperty(PropertyName = "Use native toast messages")]
    public bool UseToast;
    [JsonProperty(PropertyName = "Toast type")]
    public int Type;
  }

  #endregion
}
