using SoMRandomizer.config.settings;
using SoMRandomizer.logging;
using SoMRandomizer.processing.hacks.common.enhancement;
using SoMRandomizer.processing.hacks.common.fix;
using SoMRandomizer.processing.hacks.common.other;
using SoMRandomizer.processing.hacks.common.qol;
using SoMRandomizer.processing.hacks.common.util;
using SoMRandomizer.processing.hacks.openworld;
using SoMRandomizer.util.rng;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;

namespace SoMRandomizer.processing.common
{
    /// <summary>
    /// Parent class to all modes' rom generators.
    /// </summary>
    /// 
    /// <remarks>Author: Moppleton</remarks>
    public abstract class RomGenerator
    {
        // rando current version.  this should be updated for every release, and should always be numeric for comparison's sake.
        public static string VERSION_NUMBER = "1.48";

        private List<RandoProcessor> commonHacks = new List<RandoProcessor>();
        private List<RandoProcessor> modeSpecificHacks = new List<RandoProcessor>();
        private static byte[] rom = null;
        private static string romPath = null;
        private static readonly object Lock = new object();

        public RomGenerator()
        {
            // supported common hacks.  most of these are controlled by a settings toggle, and though they are
            // all added here, do not actually apply themselves unless the toggle has been enabled.

            // randomize weapons
            commonHacks.Add(new WeaponRandomizer()); // TODO: split into prepare and process to retain RNG
            // add name of mode to title screen
            commonHacks.Add(new TitleScreenAddition());
            // 4x magic leveling speed
            commonHacks.Add(new FasterMagicLevels());
            // 4x weapon leveling speed
            commonHacks.Add(new FasterWeaponLevels());
            // speed up status messages
            commonHacks.Add(new FasterTopOfScreenDialogue());
            // restore MP at levelup
            commonHacks.Add(new LevelupMpRestore());
            // faster chest opening
            commonHacks.Add(new ChestOpeningSpeedup());
            // change some mp costs etc
            commonHacks.Add(new MagicRebalance());
            // make statuses last longer
            commonHacks.Add(new StatusLengths());
            // random color rabites
            commonHacks.Add(new RabiteColorRandomizer());
            // set player color palettes
            commonHacks.Add(new CharacterPaletteRandomizer());
            // allow p2/p3 to scroll off screen
            commonHacks.Add(new FreeWalking());
            // disable overcharge glitch
            commonHacks.Add(new OverchargeFix());
            // disable some sound effects
            commonHacks.Add(new NoFootstepSound());
            // only allow sound effects on spc channels 6,7,8
            commonHacks.Add(new SoundEffectsReduction());
            // speed up mana sword pulling animation in intro
            commonHacks.Add(new NoSwordPullAnimation());
            // max items increased from 4 to 7
            commonHacks.Add(new MaxItemsIncrease());
            // shrug
            commonHacks.Add(new HexasFix());
            // boss AI speedup
            commonHacks.Add(new FastBosses());
            // speedup spell is haste
            commonHacks.Add(new Speedup());
            // palette glow for statuses
            commonHacks.Add(new StatusGlow());
            // vampire hittable when flying
            commonHacks.Add(new HittableVampire());
            // include starter gear pieces
            commonHacks.Add(new StarterGear());
            // randomize and fix some issue with weapon orb rewards
            commonHacks.Add(new OrbRewardEventGenerator()); // TODO: split into prepare and process to retain RNG
            // fix softlock with character animations at boss death 
            commonHacks.Add(new BossDeathFix());
            // allow enemy type damage on weapons
            commonHacks.Add(new EnemyTypeDamage());
            // allow elemental damage on weapons
            commonHacks.Add(new ElementalDamage());
            // remove fade-out from transitions
            commonHacks.Add(new FastTransitions());
            // flammie graphical bug fix
            commonHacks.Add(new Mode7EdgesFix());
            // more accurate damage percentages
            commonHacks.Add(new DamagePercentFix());
            // running doesn't cost stamina
            commonHacks.Add(new NoEnergyToRun());
            // gigases don't turn into dust
            commonHacks.Add(new GigasesNeverSplit());
            // fix some issues with whip coordinates
            commonHacks.Add(new WhipFix());
            // allow offscreen targetting
            commonHacks.Add(new FreeTargetting());
            // buy multiple consumables at once
            commonHacks.Add(new BuyMultipleItems());
            // fixed charge bonus for mana magic instead of based on weapon levels
            commonHacks.Add(new ManaMagicFix());
            // OHKO
            commonHacks.Add(new OneHitKo());
            // fix issues with summoning enemies despawning important stuff
            commonHacks.Add(new SummonDespawnFix());
            // walk through walls
            commonHacks.Add(new NoCollision());
            // fix confusion staying on forever if you save
            commonHacks.Add(new ConfuseFix());
            // make spring beak easier to hit
            commonHacks.Add(new BeakShieldDisable());
            // fix loading of enemy names for ring menu
            commonHacks.Add(new HighLevelEnemyNameFix());
            // fix map glitches when mech rider dies
            commonHacks.Add(new MechRiderDeathFix());
            // move enemies that are near doors
            commonHacks.Add(new EnemyPositionAdjustments());
            // attacks don't cost stamina
            commonHacks.Add(new NoEnergyToAttack());
            // aggressive enemies 
            commonHacks.Add(new FasterEnemies());
            // players are permanently poisoned
            commonHacks.Add(new AlwaysPoisoned());
            // poison works by percentage
            commonHacks.Add(new PercentagePoison());
            // don't show damage numbers
            commonHacks.Add(new ObscureDamage());
            // don't show own HP
            commonHacks.Add(new ObscureOwnHp());
            // don't show own gold
            commonHacks.Add(new ObscureGoldAmount());
            // change how defense applies to damage
            commonHacks.Add(new DefenseRefactor());
            // show zero damage hits for evades
            commonHacks.Add(new DisplayEvadesAsZero());
            // keep all damage numbers on screen
            commonHacks.Add(new NumbersOnScreen());
            // randomize boss elements
            commonHacks.Add(new BossElementRando());
            // allow 12 letter names
            commonHacks.Add(new NameEntryChanges());
            // enemies have infinite mp
            commonHacks.Add(new EnemyInfiniteMp());
            // fix damage canceling with items
            commonHacks.Add(new DamageCancelFix());
            // add flammie minimap
            commonHacks.Add(new Ff6StyleMiniMap());
            // allow cup of wishes on zero HP living character
            commonHacks.Add(new CupWishesOnZeroHp());
            // allow candies on zero HP living character
            commonHacks.Add(new CandyHealouts());
            // fix issue where you can die while using magic rope
            commonHacks.Add(new MagicRopeDeathFix());
            // set initial values when starting
            commonHacks.Add(new InitialValues());
            // change the mushroom enemies into vineshrooms
            commonHacks.Add(new Vineshroom());
            // default player AI to aggressive
            commonHacks.Add(new PlayerAiDefaultChanges());
        }

        protected void addModeSpecificHack(RandoProcessor modeSpecificHack)
        {
            modeSpecificHacks.Add(modeSpecificHack);
        }

        /// <summary>
        /// Prepare the generation.
        /// </summary>
        /// <param name="sourcePath">Path to the original ROM file.</param>
        /// <param name="seed">Seed value for randomization.</param>
        /// <param name="generatorsByRomType">The generators to use.</param>
        /// <param name="commonSettings">The common settings (picks which generator to use).</param>
        /// <param name="settingsByRomType">The settings for each generator.</param>
        public static RandoContext Init(string sourcePath, string seed,
            Dictionary<string, RomGenerator> generatorsByRomType, CommonSettings commonSettings,
            Dictionary<string, RandoSettings> settingsByRomType)
        {
            if (string.IsNullOrEmpty(sourcePath))
            {
                throw new Exception("No ROM selected.");
            }

            byte[] origRom; // see note below
            lock (Lock)
            {
                // NOTE: since there should only be 1 valid rom, we can cache it,
                //       however we allow replacing it in case someone uses a hack as source rom.
                if (!sourcePath.Equals(romPath))
                {
                    rom = null;
                }

                if (rom == null)
                {
                    try
                    {
                        try
                        {
                            rom = File.ReadAllBytes(sourcePath);
                        }
                        catch (Exception e)
                        {
                            throw new Exception("Cannot open source file: " + e.Message);
                        }

                        if (rom == null)
                        {
                            throw new Exception("Cannot open source file");
                        }

                        if (rom.Length != 2 * 1024 * 1024 && rom.Length != 2 * 1024 * 1024 + 0x200)
                        {
                            throw new Exception("Source file had unexpected size; should be 16 Megabit ROM");
                        }

                        // remove header from origRom
                        if (rom.Length != 2 * 1024 * 1024)
                        {
                            const int headerSize = 0x200;
                            rom = rom.Skip(headerSize).ToArray();
                        }

                        var nameChars = new char[14];
                        for (var i = 0; i < nameChars.Length; i++)
                        {
                            nameChars[i] = (char)rom[i + 0xFFC0];
                        }

                        if (new string(nameChars) != "Secret of MANA")
                        {
                            throw new Exception("Name of game did not match the expected \"Secret of MANA\"");
                        }

                        if (rom[0xFFD9] != 1)
                        {
                            throw new Exception("Please use US ROM only.");
                        }

                        if (rom[0xFFDB] != 0)
                        {
                            throw new Exception("Please use version 1.0 only.");
                        }
                    }
                    catch (Exception)
                    {
                        rom = null;
                        throw;
                    }

                    romPath = sourcePath;
                }
                origRom = rom;
            }

            try
            {
                string assemblyVersion = "" + typeof(RomGenerator).Assembly.GetName().Version;
                string[] versionTokens = assemblyVersion.Split(new char[] { '.' });
                string days = versionTokens[2];
                string minutes = versionTokens[3];
                // baseline is 01/01/2000
                DateTime date = new DateTime(2000, 1, 1)
                    // build is number of days after baseline
                    .AddDays(Int32.Parse(days))
                    // revision is half the number of seconds into the day
                    .AddSeconds(Int32.Parse(minutes) * 2);
                commonSettings.set(CommonSettings.PROPERTYNAME_BUILD_DATE, "" + date);
            }
            catch (Exception)
            {
                // shrug i guess; can't determine build date
            }

            commonSettings.set("SourceROMName", sourcePath);
            commonSettings.setInt(CommonSettings.PROPERTYNAME_CURRENT_PROGRESS, 0);

            var romType = commonSettings.get(CommonSettings.PROPERTYNAME_MODE);
            var context = generatorsByRomType[romType].init(origRom, seed, settingsByRomType[romType]);
            return context ?? throw new Exception("Error encountered! Check the log for details.");
        }

        /// <summary>
        /// Run the generation. Needs to be Prepared first.
        /// NOTE: that this will overwrite destPath if it exists. if this is undesirable,
        /// it should be checked first before calling
        /// </summary>
        /// <param name="destPath">Path to the output ROM file. This will be overwritten if it exists.</param>
        /// <param name="seed">Seed value for randomization.</param>
        /// <param name="generatorsByRomType">The generators to use.</param>
        /// <param name="commonSettings">The common settings (picks which generator to use).</param>
        /// <param name="settingsByRomType">The settings for each generator.</param>
        /// <param name="context">The context returned by Init.</param>
        public static void Run(string destPath, string seed,
            Dictionary<string, RomGenerator> generatorsByRomType, CommonSettings commonSettings,
            Dictionary<string, RandoSettings> settingsByRomType, RandoContext context)
        {
            if (string.IsNullOrEmpty(destPath))
            {
                throw new Exception("No ROM selected.");
            }

            string srcPath = commonSettings.get("SourceROMName");

            byte[] origRom;
            lock (Lock)
            {
                // grab the array in case the next prepare uses a different rom; read above
                origRom = rom;
                if (romPath != srcPath)
                {
                    throw new Exception("Failed - source changed between prepare and run!");
                }
            }

            if (origRom == null)
            {
                throw new Exception("ROM not loaded.");
            }

            if (srcPath == destPath)
            {
                throw new Exception("Failed - source and destination file are the same!");
            }

            try
            {
                FileStream fs = new FileStream(destPath, FileMode.Create, FileAccess.Write);
                if (!fs.CanWrite)
                {
                    throw new Exception("Can't write to destination file.");
                }
                fs.Close();
            }
            catch (Exception)
            {
                throw new Exception("Can't write to destination file.");
            }

            Debug.Assert(origRom.Length == 2 * 1024 * 1024);
            byte[] outFile = new byte[4 * 1024 * 1024];
            for (int i = 0; i < 2 * 1024 * 1024; i++)
            {
                outFile[i] = origRom[i];
            }

            // this is just for logging later
            commonSettings.set("TargetROMName", destPath);

            string romType = commonSettings.get(CommonSettings.PROPERTYNAME_MODE);
            bool success = generatorsByRomType[romType].run(origRom, outFile, seed, settingsByRomType[romType], context);
            if (success)
            {
                File.WriteAllBytes(destPath, outFile);
            }
            else
            {
                throw new Exception("Error encountered! Check the log for details.");
            }
            commonSettings.setInt(CommonSettings.PROPERTYNAME_CURRENT_PROGRESS, 0);
        }

        private RandoContext init(byte[] origRom, string seed, RandoSettings settings)
        {
            var context = new RandoContext
            {
                // https://github.com/dotnet/coreclr/blob/release/1.1.0/src/mscorlib/src/System/Random.cs/
                // https://github.com/mono/mono/blob/master/mcs/class/Mono.C5/C5/Random.cs
                randomFunctional = new DotNet110Random(HashcodeUtil.GetDeterministicHashCode(seed)),
                // different random for cosmetics, so you can change them and not impact the rando
                randomCosmetic = new DotNet110Random(HashcodeUtil.GetDeterministicHashCode(seed + "_cosmetic")),
                originalRom = origRom
            };

            if (settings.getBool(CommonSettings.PROPERTYNAME_RACE_MODE))
            {
                // advance randomness state up to 8 times.
                // no clue if this is needed, once might be fine as well
                int times = context.randomFunctional.Next(8);
                for (; times > 0; times--) context.randomFunctional.Next();
            }

            // create loggers
            String filenameSeed = seed;
            char[] badChars = new char[] { '\\', '/', ':', '<', '>', '\'', '\"', '*', '?', '|' };
            foreach (char c in badChars)
            {
                filenameSeed = filenameSeed.Replace(c, '_'); // TODO: make directory configurable
            }
            Logging.ClearLoggers();
            if (settings.getBool(CommonSettings.PROPERTYNAME_TEST_ONLY))
            {
                context.fileLogger = new DebugLogger();
                context.fileLoggerSpoiler = new DebugLogger();
                context.fileLoggerDebug = new DebugLogger();
            }
            else
            {
                context.fileLogger = new FileLogger("./log_" + filenameSeed + ".txt");
                if (settings.getBool(CommonSettings.PROPERTYNAME_SPOILER_LOG) && !settings.getBool(CommonSettings.PROPERTYNAME_RACE_MODE))
                {
                    context.fileLoggerSpoiler = new FileLogger("./log_" + filenameSeed + "_SPOILER.txt");
                }
                else
                {
                    context.fileLoggerSpoiler = new NullWriter();
                }
                if (settings.getBool(CommonSettings.PROPERTYNAME_DEBUG_LOG))
                {
                    context.fileLoggerDebug = new FileLogger("./log_" + filenameSeed + "_DEBUG.txt");
                }
                else
                {
                    context.fileLoggerDebug = new NullWriter();
                }
            }

            lock (Logging.specificMessageTypeLoggers)
            {
                // TODO: make Logging thread local so we don't need to lock here
                Logging.ClearLoggers();
                Logging.AddLogger(context.fileLogger);
                Logging.AddLogger("spoiler", context.fileLoggerSpoiler);
                Logging.AddLogger("debug", context.fileLoggerDebug);
                Logging.AddLogger(context.fileLoggerDebug); // include general messages in the debug log, too
                Logging.debugEnabled = true;

                try
                {
                    // prepare the hack(s)
                    prepare(origRom, seed, settings, context);
                }
                catch (Exception e)
                {
                    Logging.log(
                        "Exception encountered! Exception message: " + e.Message + "\nStack trace:\n" + e.StackTrace);
                    throw; // for now, since there is no early logging yet
                    return null;
                }
                finally
                {
                    Logging.ClearLoggers();
                }
            }

            return context;
        }

        private bool run(byte[] origRom, byte[] outRom, string seed, RandoSettings settings, RandoContext context)
        {
            context.outputRom = outRom;
            context.namesOfThings = new NamesOfThings(outRom); // TODO: move this to init()

            lock (Logging.specificMessageTypeLoggers)
            {
                // TODO: make Logging thread local so we don't need to lock here
                // restore logger from context
                Logging.ClearLoggers();
                Logging.AddLogger(context.fileLogger);
                Logging.AddLogger("spoiler", context.fileLoggerSpoiler);
                Logging.AddLogger("debug", context.fileLoggerDebug);
                Logging.AddLogger(context.fileLoggerDebug); // include general messages in the debug log, too
                Logging.debugEnabled = true;

                // log all the incoming settings, and seed
                Logging.log("-------------------------------------------------");
                Logging.log("Begin ROM generation at " + DateTime.Now + " with version " +
                            settings.get(CommonSettings.PROPERTYNAME_VERSION));
                Logging.log("Seed = " + seed);
                Logging.log("Options = " + settings);
                Logging.log("-------------------------------------------------");

                try
                {
                    // main rom generation
                    generate(origRom, outRom, seed, settings, context);
                    // a few post-processing steps with data defined by the above
                    context.namesOfThings.setAllNames(outRom, ref context.workingOffset);
                    if (context.replacementEvents.Count > 0)
                    {
                        EventExpander eventExpander = new EventExpander();
                        eventExpander.process(outRom, context.replacementEvents, ref context.workingOffset);
                    }

                    if (context.replacementMapPieces.Count > 0)
                    {
                        MapPieceExpander mapPieceExpander = new MapPieceExpander();
                        mapPieceExpander.process(outRom, context.replacementMapPieces, ref context.workingOffset);
                    }

                    if (context.replacementDoors.Count > 0)
                    {
                        DoorReplacer doorReplacer = new DoorReplacer();
                        doorReplacer.process(outRom, context);
                    }

                    if (context.generatedMaps.Count > 0)
                    {
                        FullMapReplacer fullMapReplacer = new FullMapReplacer();
                        fullMapReplacer.process(outRom, context);
                    }

                    if (context.replacementMapPalettes.Count > 0)
                    {
                        MapPaletteSetReplacer mapPaletteSetReplacer = new MapPaletteSetReplacer();
                        mapPaletteSetReplacer.process(context);
                    }

                    context.eventHackMgr.process(outRom, ref context.workingOffset);
                    Logging.log("Done!");
                    return true;
                }
                catch (Exception e)
                {
                    Logging.log("Exception encountered! Exception message: " + e.Message + "\nStack trace:\n" +
                                e.StackTrace);
                    return false;
                }
                finally
                {
                    context.fileLogger?.close();
                    context.fileLoggerSpoiler?.close();
                    context.fileLoggerDebug?.close();
                    // TODO: or context.dispose()?
                    Logging.ClearLoggers();
                }
            }
        }

        // ReSharper disable once InconsistentNaming
        protected abstract void prepare(byte[] origRom, string seed, RandoSettings settings, RandoContext context);

        // ReSharper disable once InconsistentNaming
        protected abstract void generate(byte[] origRom, byte[] outRom, String seed, RandoSettings settings, RandoContext context);

        private void applySharedHacks(byte[] origRom, byte[] outRom, String seed, RandoSettings settings, RandoContext context)
        {
            
        }

        private void applyModeSpecificHacks(byte[] origRom, byte[] outRom, String seed, RandoSettings settings, RandoContext context)
        {

        }

        // ReSharper disable once InconsistentNaming
        protected void prepareHacks(byte[] origRom, string seed, RandoSettings settings, RandoContext context)
        {
            foreach (var commonHack in commonHacks)
            {
                commonHack.prepare(origRom, seed, settings, context);
            }
            foreach (var modeSpecificHack in modeSpecificHacks)
            {
                modeSpecificHack.prepare(origRom, seed, settings, context);
            }
        }

        // ReSharper disable once InconsistentNaming
        protected void applyHacks(byte[] origRom, byte[] outRom, String seed, RandoSettings settings, RandoContext context)
        {
            foreach (var commonHack in commonHacks)
            {
                commonHack.add(origRom, outRom, seed, settings, context);
            }
            foreach (var modeSpecificHack in modeSpecificHacks)
            {
                modeSpecificHack.add(origRom, outRom, seed, settings, context);
            }
        }

        protected RandoProcessor GetModeSpecificHack(Type type)
        {
            return modeSpecificHacks.FirstOrDefault(hack => hack.GetType() == type);
        }
    }
}
