/*
 * Copyright (C) 2012-2017 CypherCore <http://github.com/CypherCore>
 * 
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

using Framework.Configuration;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;

namespace Framework.Database
{
    public class DatabaseUpdater<T>
    {
        public DatabaseUpdater(MySqlBase<T> database)
        {
            _database = database;
        }

        public bool Populate()
        {
            SQLResult result = _database.Query("SHOW TABLES");
            if (!result.IsEmpty() && result.GetRowCount() > 0)
                return true;

            Log.outInfo(LogFilter.SqlUpdates, "Database {0} is empty, auto populating it...", _database.GetDatabaseName());

            string path = GetSourceDirectory();
            string fileName = "Unknown";
            switch (_database.GetType().Name)
            {
                case "LoginDatabase":
                    fileName = @"\sql\base\auth_database.sql";
                    break;
                case "CharacterDatabase":
                    fileName = @"\sql\base\characters_database.sql";
                    break;
                case "WorldDatabase":
                    fileName = @"\sql\base\TDB_world_703.00_2016_10_17.sql";
                    break;
                case "HotfixDatabase":
                    fileName = @"\sql\base\TDB_hotfixes_703.00_2016_10_17.sql";
                    break;     
            }

            if (!File.Exists(path + fileName))
            {
                Log.outError(LogFilter.SqlUpdates, "File \"{0}\" is missing, download it from \"http://www.trinitycore.org/f/files/category/1-database/\"" +
                    " and place it in your worldserver directory.", fileName);
                return false;
            }

            // Update database
            Log.outInfo(LogFilter.SqlUpdates, "Applying \'{0}\'...", fileName);
            _database.ApplyFile(path + fileName);

            Log.outInfo(LogFilter.SqlUpdates, "Done Applying \'{0}\'", fileName);
            return true;
        }

        public bool Update()
        {
            Log.outInfo(LogFilter.SqlUpdates, "Updating {0} database...", _database.GetDatabaseName());

            string sourceDirectory = GetSourceDirectory();

            if (!Directory.Exists(sourceDirectory))
            {
                Log.outError(LogFilter.SqlUpdates, "DBUpdater: Given source directory {0} does not exist, skipped!", sourceDirectory);
                return false;
            }

            var availableFiles = GetFileList();
            var appliedFiles = ReceiveAppliedFiles();

            bool redundancyChecks = ConfigMgr.GetDefaultValue("Updates.Redundancy", true);
            bool archivedRedundancy = ConfigMgr.GetDefaultValue("Updates.Redundancy", true);

            UpdateResult result = new UpdateResult();

            // Count updates
            foreach (var entry in appliedFiles)
            {
                if (entry.Value.State == State.RELEASED)
                    ++result.recent;
                else
                    ++result.archived;
            }

            foreach (var availableQuery in availableFiles)
            {
                Log.outDebug(LogFilter.SqlUpdates, "Checking update \"{0}\"...", availableQuery.GetFileName());

                var applied = appliedFiles.LookupByKey(availableQuery.GetFileName());
                if (applied != null)
                {
                    // If redundancy is disabled skip it since the update is already applied.
                    if (!redundancyChecks)
                    {
                        Log.outDebug(LogFilter.SqlUpdates, "Update is already applied, skipping redundancy checks.");
                        appliedFiles.Remove(availableQuery.GetFileName());
                        continue;
                    }

                    // If the update is in an archived directory and is marked as archived in our database skip redundancy checks (archived updates never change).
                    if (!archivedRedundancy && (applied.State == State.ARCHIVED) && (availableQuery.state == State.ARCHIVED))
                    {
                        Log.outDebug(LogFilter.SqlUpdates, "Update is archived and marked as archived in database, skipping redundancy checks.");
                        appliedFiles.Remove(availableQuery.GetFileName());
                        continue;
                    }
                }

                // Calculate hash
                string hash = CalculateHash(availableQuery.path);

                UpdateMode mode = UpdateMode.Apply;

                // Update is not in our applied list
                if (applied == null)
                {
                    // Catch renames (different filename but same hash)
                    var hashIter = appliedFiles.Values.FirstOrDefault(p => p.Hash == hash);
                    if (hashIter != null)
                    {
                        // Check if the original file was removed if not we've got a problem.
                        var renameFile = availableFiles.Find(p => p.GetFileName() == hashIter.Name);
                        if (renameFile != null)
                        {
                            Log.outWarn(LogFilter.SqlUpdates, "Seems like update \"{0}\" \'{1}\' was renamed, but the old file is still there! " +
                                "Trade it as a new file! (Probably its an unmodified copy of file \"{2}\")",
                                    availableQuery.GetFileName(), hash.Substring(0, 7), renameFile.GetFileName());
                        }
                        // Its save to trade the file as renamed here
                        else
                        {
                            Log.outInfo(LogFilter.SqlUpdates, "Renaming update \"{0}\" to \"{1}\" \'{2}\'.",
                                hashIter.Name, availableQuery.GetFileName(), hash.Substring(0, 7));

                            RenameEntry(hashIter.Name, availableQuery.GetFileName());
                            appliedFiles.Remove(hashIter.Name);
                            continue;
                        }
                    }
                    // Apply the update if it was never seen before.
                    else
                    {
                        Log.outInfo(LogFilter.SqlUpdates, "Applying update \"{0}\" \'{1}\'...", availableQuery.GetFileName(), hash.Substring(0, 7));
                    }
                }
                // Rehash the update entry if it is contained in our database but with an empty hash.
                else if (ConfigMgr.GetDefaultValue("Updates.AllowRehash", true) && string.IsNullOrEmpty(applied.Hash))
                {
                    mode = UpdateMode.Rehash;

                    Log.outInfo(LogFilter.SqlUpdates, "Re-hashing update \"{0}\" \'{1}\'...", availableQuery.GetFileName(), hash.Substring(0, 7));
                }
                else
                {
                    // If the hash of the files differs from the one stored in our database reapply the update (because it was changed).
                    if (applied.Hash != hash)
                    {
                        Log.outInfo(LogFilter.SqlUpdates, "Reapplying update \"{0}\" \'{1}\' . \'{2}\' (it changed)...", availableQuery.GetFileName(),
                            applied.Hash.Substring(0, 7), hash.Substring(0, 7));
                    }
                    else
                    {
                        // If the file wasn't changed and just moved update its state if necessary.
                        if (applied.State != availableQuery.state)
                        {
                            Log.outDebug(LogFilter.SqlUpdates, "Updating state of \"{0}\" to \'{1}\'...", availableQuery.GetFileName(), availableQuery.state);

                            UpdateState(availableQuery.GetFileName(), availableQuery.state);
                        }

                        Log.outDebug(LogFilter.SqlUpdates, "Update is already applied and is matching hash \'{0}\'.", hash.Substring(0, 7));

                        appliedFiles.Remove(applied.Name);
                        continue;
                    }
                }

                uint speed = 0;
                AppliedFileEntry file = new AppliedFileEntry(availableQuery.GetFileName(), hash, availableQuery.state, 0);

                switch (mode)
                {
                    case UpdateMode.Apply:
                        speed = ApplyTimedFile(availableQuery.path);
                        goto case UpdateMode.Rehash;
                    case UpdateMode.Rehash:
                        UpdateEntry(file, speed);
                        break;
                }

                if (applied != null)
                    appliedFiles.Remove(applied.Name);

                if (mode == UpdateMode.Apply)
                    ++result.updated;
            }

            // Cleanup up orphaned entries if enabled
            if (!appliedFiles.Empty())
            {
                int cleanDeadReferencesMaxCount = ConfigMgr.GetDefaultValue("Updates.CleanDeadRefMaxCount", 3);
                bool doCleanup = (cleanDeadReferencesMaxCount < 0) || (appliedFiles.Count <= cleanDeadReferencesMaxCount);

                foreach (var entry in appliedFiles)
                {
                    Log.outWarn(LogFilter.SqlUpdates, "File \'{0}\' was applied to the database but is missing in your update directory now!", entry.Key);

                    if (doCleanup)
                        Log.outInfo(LogFilter.SqlUpdates, "Deleting orphaned entry \'{0}\'...", entry.Key);
                }

                if (doCleanup)
                    CleanUp(appliedFiles);
                else
                {
                    Log.outError(LogFilter.SqlUpdates, "Cleanup is disabled! There are {0} dirty files that were applied to your database but are now missing in your source directory!", appliedFiles.Count);
                }
            }

            string info = string.Format("Containing {0} new and {1} archived updates.", result.recent, result.archived);

            if (result.updated == 0)
                Log.outInfo(LogFilter.SqlUpdates, "{0} database is up-to-date! {1}", _database.GetDatabaseName(), info);
            else
                Log.outInfo(LogFilter.SqlUpdates, "Applied {0} {1}. {2}", result.updated, result.updated == 1 ? "query" : "queries", info);

            return true;
        }

        string GetSourceDirectory()
        {
            string entry = ConfigMgr.GetDefaultValue("Updates.SourcePath", Environment.CurrentDirectory);
            if (!string.IsNullOrEmpty(entry))
                return entry;
            else
                return Directory.GetParent(Directory.GetCurrentDirectory()).Parent.FullName;
        }

        uint ApplyTimedFile(string path)
        {
            // Benchmark query speed
            uint oldMSTime = Time.GetMSTime();

            // Update database
            _database.ApplyFile(path);

            // Return time the query took to apply
            return Time.GetMSTimeDiffToNow(oldMSTime);
        }

        void UpdateEntry(AppliedFileEntry entry, uint speed)
        {
            string update = "REPLACE INTO `updates` (`name`, `hash`, `state`, `speed`) VALUES (\"" +
                        entry.Name + "\", \"" + entry.Hash + "\", \'" + entry.State + "\', " + speed + ")";

            // Update database
            _database.Execute(update);
        }

        void RenameEntry(string from, string to)
        {
            // Delete target if it exists
            {
                string update = "DELETE FROM `updates` WHERE `name`=\"" + to + "\"";

                // Update database
                _database.Execute(update);
            }

            // Rename
            {
                string update = "UPDATE `updates` SET `name`=\"" + to + "\" WHERE `name`=\"" + from + "\"";

                // Update database
                _database.Execute(update);
            }
        }

        void CleanUp(Dictionary<string, AppliedFileEntry> storage)
        {
            if (storage.Empty())
                return;

            int remaining = storage.Count;
            string update = "DELETE FROM `updates` WHERE `name` IN(";

            foreach (var entry in storage)
            {
                update += "\"" + entry.Key + "\"";
                if ((--remaining) > 0)
                    update += ", ";
            }

            update += ")";

            // Update database
            _database.Execute(update);
        }

        void UpdateState(string name, State state)
        {
            string update = "UPDATE `updates` SET `state`=\'" + state + "\' WHERE `name`=\"" + name + "\"";

            // Update database
            _database.Execute(update);
        }

        public List<FileEntry> GetFileList()
        {
            List<FileEntry> fileList = new List<FileEntry>();

            SQLResult result = _database.Query("SELECT `path`, `state` FROM `updates_include`");
            if (result.IsEmpty())
                return fileList;

            do
            {
                string path = result.Read<string>(0);
                if (path[0] == '$')
                    path = GetSourceDirectory() + path.Substring(1);

                if (!Directory.Exists(path))
                {
                    Log.outWarn(LogFilter.SqlUpdates, "DBUpdater: Given update include directory \"{0}\" isn't existing, skipped!", path);
                    continue;
                }

                State state = result.Read<string>(1).ToEnum<State>();
                fileList.AddRange(GetFilesFromDirectory(path, state));

                Log.outDebug(LogFilter.SqlUpdates, "Added applied file \"{0}\" from remote.", path);

            } while (result.NextRow());

            return fileList;
        }

        public Dictionary<string, AppliedFileEntry> ReceiveAppliedFiles()
        {
            Dictionary<string, AppliedFileEntry> map = new Dictionary<string, AppliedFileEntry>();

            SQLResult result = _database.Query("SELECT `name`, `hash`, `state`, UNIX_TIMESTAMP(`timestamp`) FROM `updates` ORDER BY `name` ASC");
            if (result.IsEmpty())
                return map;

            do
            {
                AppliedFileEntry entry = new AppliedFileEntry(result.Read<string>(0), result.Read<string>(1), result.Read<string>(2).ToEnum<State>(), result.Read<ulong>(3));
                map.Add(entry.Name, entry);
            }
            while (result.NextRow());

            return map;
        }

        IEnumerable<FileEntry> GetFilesFromDirectory(string directory, State state)
        {
            Queue<string> queue = new Queue<string>();
            queue.Enqueue(directory);
            while (queue.Count > 0)
            {
                directory = queue.Dequeue();
                try
                {
                    foreach (string subDir in Directory.GetDirectories(directory))
                    {
                        queue.Enqueue(subDir);
                    }
                }
                catch (Exception ex)
                {
                    Console.Error.WriteLine(ex);
                }

                string[] files = Directory.GetFiles(directory, "*.sql");
                for (int i = 0; i < files.Length; i++)
                {
                    yield return new FileEntry(files[i], state);
                }
            }
        }

        string CalculateHash(string fileName)
        {
            using (SHA1 sha1 = new SHA1Managed())
            {
                string text = File.ReadAllText(fileName).Replace("\r", "");
                return sha1.ComputeHash(Encoding.UTF8.GetBytes(text)).ToHexString();
            }
        }

        protected MySqlBase<T> _database;
    }

    public class AppliedFileEntry
    {
        public AppliedFileEntry(string name, string hash, State state, ulong timestamp)
        {
            Name = name;
            Hash = hash;
            State = state;
            Timestamp = timestamp;
        }

        public string Name;
        public string Hash;
        public State State;
        public ulong Timestamp;
    }

    public class FileEntry
    {
        public FileEntry(string _path, State _state)
        {
            path = _path;
            state = _state;
        }

        public string GetFileName()
        {
            return Path.GetFileName(path);
        }

        public string path;
        public State state;
    }

    struct UpdateResult
    {
        public int updated;
        public int recent;
        public int archived;
    }

    public enum State
    {
        RELEASED,
        ARCHIVED
    }

    enum UpdateMode
    {
        Apply,
        Rehash
    }
}
