package funkin.util.plugins;

import flixel.FlxBasic;
import flixel.FlxG;
import flixel.FlxState;
import flixel.tweens.FlxEase;
import flixel.tweens.FlxTween;
import flixel.util.FlxColor;
import flixel.util.FlxSignal;
import flixel.util.FlxTimer;
import funkin.util.WindowUtil;
import funkin.util.logging.CrashHandler;
import flixel.addons.util.FlxAsyncLoop;
import funkin.input.Cursor;
import funkin.audio.FunkinSound;
import openfl.display.Bitmap;
import openfl.display.Sprite;
import openfl.display.BitmapData;
import openfl.display.PNGEncoderOptions;
import openfl.geom.Rectangle;
import openfl.utils.ByteArray;
import openfl.events.MouseEvent;
import funkin.Preferences;

/**
 * Parameters for starting the `ScreenshotPlugin`.
 */
typedef ScreenshotPluginParams =
{
  ?region:Rectangle,
};

/**
 * A Flixel plugin, which renders on top of the game, and provides the functionality
 * and visual feedback for taking screenshots.
 */
@:nullSafety
class ScreenshotPlugin extends FlxBasic
{
  /**
   * Current `ScreenshotPlugin` instance
   */
  public static var instance(get, never):ScreenshotPlugin;

  static var _instance:Null<ScreenshotPlugin> = null;

  static function get_instance():ScreenshotPlugin
  {
    if (_instance == null)
    {
      _instance = new ScreenshotPlugin({});
    }
    return _instance;
  }

  /**
   * The folder where screenshots are saved
   */
  public static final SCREENSHOT_FOLDER:String = 'screenshots';

  var region:Null<Rectangle>;

  /**
   * A signal fired before the screenshot is taken.
   */
  public var onPreScreenshot(default, null):FlxSignal;

  /**
   * A signal fired after the screenshot is taken.
   * @param bitmap The bitmap that was captured.
   */
  public var onPostScreenshot(default, null):FlxTypedSignal<Bitmap->Void>;

  static var lastWidth:Int = 0;
  static var lastHeight:Int = 0;

  var flashSprite:Sprite;
  var flashBitmap:Bitmap;
  var previewSprite:Sprite;
  var shotPreviewBitmap:Bitmap;
  var outlineBitmap:Bitmap;

  var wasMouseHidden:Bool = false; // Used for hiding and then showing the mouse
  var wasMouseShown:Bool = false; // Used for showing and then hiding the mouse
  var screenshotTakenFrame:Int = 0;

  var screenshotBeingSpammed:Bool = false;

  var screenshotSpammedTimer:Null<FlxTimer> = null;

  var screenshotBuffer:Array<Bitmap> = [];
  var screenshotNameBuffer:Array<String> = [];

  var unsavedScreenshotBuffer:Array<Bitmap> = [];
  var unsavedScreenshotNameBuffer:Array<String> = [];

  var stateChanging:Bool = false;
  var noSavingScreenshots:Bool = false;

  var flashTween:Null<FlxTween> = null;

  var previewFadeInTween:Null<FlxTween> = null;
  var previewFadeOutTween:Null<FlxTween> = null;

  var asyncLoop:Null<FlxAsyncLoop> = null;

  public function new(params:ScreenshotPluginParams)
  {
    super();

    lastWidth = FlxG.width;
    lastHeight = FlxG.height;

    flashSprite = new Sprite();
    flashSprite.mouseEnabled = false;
    flashSprite.alpha = 0;
    flashBitmap = new Bitmap(new BitmapData(lastWidth, lastHeight, true, Preferences.flashingLights ? FlxColor.WHITE : FlxColor.TRANSPARENT));
    flashSprite.addChild(flashBitmap);

    previewSprite = new Sprite();
    previewSprite.alpha = 0;

    outlineBitmap = new Bitmap(new BitmapData(Std.int(lastWidth / 5) + 10, Std.int(lastHeight / 5) + 10, true, 0xFFFFFFFF));
    outlineBitmap.x = 5;
    outlineBitmap.y = 5;
    previewSprite.addChild(outlineBitmap);

    shotPreviewBitmap = new Bitmap();
    shotPreviewBitmap.scaleX /= 5;
    shotPreviewBitmap.scaleY /= 5;

    previewSprite.addChild(shotPreviewBitmap);
    FlxG.stage.addChild(flashSprite);

    region = params.region ?? null;

    onPreScreenshot = new FlxTypedSignal<Void->Void>();
    onPostScreenshot = new FlxTypedSignal<Bitmap->Void>();
    FlxG.signals.gameResized.add(this.resizeBitmap);
    FlxG.signals.preStateSwitch.add(this.saveUnsavedBufferedScreenshots);
    FlxG.signals.postStateSwitch.add(this.postStateSwitch);
    // Called when clicking the X button on the window.
    WindowUtil.windowExit.add(onWindowClose);

    // Called when the game crashes.
    CrashHandler.errorSignal.add(onWindowCrash);
    CrashHandler.criticalErrorSignal.add(onWindowCrash);
  }

  public override function update(elapsed:Float):Void
  {
    if (asyncLoop != null)
    {
      // If the loop hasn't started yet, start it
      if (!asyncLoop.started)
      {
        asyncLoop.start();
      }
      else
      {
        // if the loop has been started, and is finished, then we kill. it
        if (asyncLoop.finished)
        {
          if (screenshotBuffer != [])
          {
            trace("finished processing screenshot buffer");
            screenshotBuffer = [];
            screenshotNameBuffer = [];
          }
          // your honor, league of legends
          asyncLoop.kill();
          asyncLoop.destroy();
          asyncLoop = null;
        }
      }
      // Examples ftw!
    }
    super.update(elapsed);

    /**
     * This looks scary, oh no I pressed the button but no screenshot because screenshotTakenFrame != 0!
     * But if you're crazy enough to have a macro that bumps into this
     * then you're probably also going to hit 100 screenshots real fast
     */
    if (hasPressedScreenshot() && screenshotTakenFrame == 0)
    {
      if (FlxG.keys.pressed.SHIFT)
      {
        openScreenshotsFolder();
        return; // We're only opening the screenshots folder (we don't want to accidentally take a screenshot after this)
      }
      if (Preferences.shouldHideMouse && !wasMouseHidden && FlxG.mouse.visible)
      {
        wasMouseHidden = true;
        Cursor.hide();
      }
      for (sprite in [flashSprite, previewSprite])
      {
        FlxTween.cancelTweensOf(sprite);
        sprite.alpha = 0;
      }
      // screenshot spamming timer
      if (screenshotSpammedTimer == null || screenshotSpammedTimer.finished)
      {
        screenshotSpammedTimer = new FlxTimer().start(1, function(_) {
          // The player's stopped spamming shots, so we can stop the screenshot spam mode too
          screenshotBeingSpammed = false;
          if (screenshotBuffer[0] != null) saveBufferedScreenshots(screenshotBuffer, screenshotNameBuffer);
          if (!Preferences.fancyPreview && wasMouseHidden && !FlxG.mouse.visible)
          {
            wasMouseHidden = false;
            Cursor.show();
          }
        });
      }
      else // Pressing the screenshot key more than once every second enables the screenshot spam mode and resets the timer
      {
        screenshotBeingSpammed = true;
        screenshotSpammedTimer.reset(1);
      }
      FlxG.stage.removeChild(previewSprite);
      screenshotTakenFrame++;
    }
    else if (screenshotTakenFrame > 1)
    {
      screenshotTakenFrame = 0;
      capture(); // After all these checks and waiting a frame, we finally try taking a screenshot
    }
    else if (screenshotTakenFrame > 0)
    {
      screenshotTakenFrame++;
    }
  }

  /**
   * Initialize the screenshot plugin.
   */
  public static function initialize():Void
  {
    FlxG.plugins.addPlugin(new ScreenshotPlugin({}));
  }

  /**
   * @return `true` if the screenshot key is currently pressed.
   */
  public function hasPressedScreenshot():Bool
  {
    #if FEATURE_SCREENSHOTS
    return PlayerSettings.player1.controls.WINDOW_SCREENSHOT;
    #else
    return false;
    #end
  }

  function resizeBitmap(width:Int, height:Int):Void
  {
    lastWidth = width;
    lastHeight = height;
    flashBitmap.bitmapData = new BitmapData(lastWidth, lastHeight, true, Preferences.flashingLights ? FlxColor.WHITE : FlxColor.TRANSPARENT);
    outlineBitmap.bitmapData = new BitmapData(Std.int(lastWidth / 5) + 10, Std.int(lastHeight / 5) + 10, true, 0xFFFFFFFF);
  }

  /**
   * Capture the game screen as a bitmap.
   */
  public function capture():Void
  {
    onPreScreenshot.dispatch();

    var shot:Bitmap = new Bitmap(BitmapData.fromImage(FlxG.stage.window.readPixels()));
    if (screenshotBeingSpammed)
    {
      // Save the screenshots to the buffer instead
      if (screenshotBuffer.length < 100)
      {
        screenshotBuffer.push(shot);
        screenshotNameBuffer.push('screenshot-${DateUtil.generateTimestamp()}');

        unsavedScreenshotBuffer.push(shot);
        unsavedScreenshotNameBuffer.push('screenshot-${DateUtil.generateTimestamp()}');
      }
      else
      {
        noSavingScreenshots = true;
        throw "You've tried taking more than 100 screenshots at a time. Give the game a funkin break! Jeez. If you wanted those screenshots, well too bad!";
      }
      showCaptureFeedback();
      if (wasMouseHidden && !FlxG.mouse.visible && Preferences.flashingLights) // Just in case
      {
        wasMouseHidden = false;
        Cursor.show();
      }
      if (!Preferences.previewOnSave) showFancyPreview(shot);
    }
    else
    {
      // Save the screenshot immediately, so it doesn't get lost by a state change
      saveScreenshot(shot, 'screenshot-${DateUtil.generateTimestamp()}', 1, false);
      // Show some feedback.
      showCaptureFeedback();
      if (wasMouseHidden && !FlxG.mouse.visible)
      {
        wasMouseHidden = false;
        Cursor.show();
      }
      if (!Preferences.previewOnSave) showFancyPreview(shot);
    }
    onPostScreenshot.dispatch(shot);
  }

  static final CAMERA_FLASH_DURATION:Float = 0.25;

  /**
   * Visual and audio feedback when a screenshot is taken.
   */
  function showCaptureFeedback():Void
  {
    if (stateChanging) return; // Flash off!
    flashSprite.alpha = 1;
    FlxTween.tween(flashSprite, {alpha: 0}, 0.15);

    FunkinSound.playOnce(Paths.sound('screenshot'), 1.0);
  }

  static final PREVIEW_INITIAL_DELAY:Float = 0.25; // How long before the preview starts fading in.
  static final PREVIEW_FADE_IN_DURATION:Float = 0.3; // How long the preview takes to fade in.
  static final PREVIEW_FADE_OUT_DELAY:Float = 1.25; // How long the preview stays on screen.
  static final PREVIEW_FADE_OUT_DURATION:Float = 0.3; // How long the preview takes to fade out.

  /**
   * Show a fancy preview for the screenshot
   */
  function showFancyPreview(shot:Bitmap):Void
  {
    if (!Preferences.fancyPreview || screenshotBeingSpammed && !Preferences.flashingLights || stateChanging) return; // Sorry, the previews' been cancelled
    shotPreviewBitmap.bitmapData = shot.bitmapData;
    shotPreviewBitmap.x = outlineBitmap.x + 5;
    shotPreviewBitmap.y = outlineBitmap.y + 5;

    shotPreviewBitmap.width = outlineBitmap.width - 10;
    shotPreviewBitmap.height = outlineBitmap.height - 10;

    // Remove the existing preview
    FlxG.stage.removeChild(previewSprite);

    // ermmm stealing this??

    if (!wasMouseShown && !wasMouseHidden && !FlxG.mouse.visible)
    {
      wasMouseShown = true;
      Cursor.show();
    }

    // so that it doesnt change the alpha when tweening in/out
    var changingAlpha:Bool = false;
    var targetAlpha:Float = 1;

    // fuck it, cursed locally scoped functions, purely because im lazy
    // (and so we can check changingAlpha, which is locally scoped.... because I'm lazy...)
    var onHover:MouseEvent->Void = function(e:MouseEvent) {
      if (!changingAlpha) e.target.alpha = 0.6;
      targetAlpha = 0.6;
    };

    var onHoverOut:MouseEvent->Void = function(e:MouseEvent) {
      if (!changingAlpha) e.target.alpha = 1;
      targetAlpha = 1;
    }

    // used for movement + button stuff
    previewSprite.buttonMode = true;
    previewSprite.addEventListener(MouseEvent.MOUSE_DOWN, previewSpriteOpenScreenshotsFolder);
    previewSprite.addEventListener(MouseEvent.MOUSE_MOVE, onHover);
    previewSprite.addEventListener(MouseEvent.MOUSE_OUT, onHoverOut);

    FlxTween.cancelTweensOf(previewSprite); // Reset the tweens
    FlxG.stage.addChild(previewSprite);
    previewSprite.alpha = 0.0;
    previewSprite.y -= 10;
    // set the alpha to 0.6 if the mouse is already over the preview sprite
    if (previewSprite.hitTestPoint(previewSprite.mouseX, previewSprite.mouseY)) targetAlpha = 0.6;
    // Wait to fade in.
    new FlxTimer().start(PREVIEW_INITIAL_DELAY, function(_) {
      // Fade in.
      changingAlpha = true;
      FlxTween.tween(previewSprite, {alpha: targetAlpha, y: 0}, PREVIEW_FADE_IN_DURATION,
        {
          ease: FlxEase.quartOut,
          onComplete: function(_) {
            changingAlpha = false;
            // Wait to fade out.
            new FlxTimer().start(PREVIEW_FADE_OUT_DELAY, function(_) {
              changingAlpha = true;
              // Fade out.
              FlxTween.tween(previewSprite, {alpha: 0.0, y: 10}, PREVIEW_FADE_OUT_DURATION,
                {
                  ease: FlxEase.quartInOut,
                  onComplete: function(_) {
                    if (wasMouseShown && FlxG.mouse.visible)
                    {
                      wasMouseShown = false;
                      Cursor.hide();
                    }
                    else if (wasMouseHidden && !FlxG.mouse.visible)
                    {
                      wasMouseHidden = false;
                      Cursor.show();
                    }

                    previewSprite.removeEventListener(MouseEvent.MOUSE_DOWN, previewSpriteOpenScreenshotsFolder);
                    previewSprite.removeEventListener(MouseEvent.MOUSE_OVER, onHover);
                    previewSprite.removeEventListener(MouseEvent.MOUSE_OUT, onHoverOut);

                    FlxG.stage.removeChild(previewSprite);
                  }
                });
            });
          }
        });
    });
  }

  /**
   * This is a separate function, as running the previewsprite check
   * in the other one would mean you can't open the folder when the preview's hidden, lol
   * That, and it needs a mouse event as a parameter to work.
   */
  function previewSpriteOpenScreenshotsFolder(e:MouseEvent):Void
  {
    if (previewSprite.alpha <= 0) return;
    openScreenshotsFolder();
  }

  function openScreenshotsFolder():Void
  {
    FileUtil.openFolder(SCREENSHOT_FOLDER);
  }

  // Save them, save the screenshots
  function onWindowClose(exitCode:Int):Void
  {
    if (noSavingScreenshots) return; // sike
    saveUnsavedBufferedScreenshots();
  }

  function onWindowCrash(message:String):Void
  {
    if (noSavingScreenshots) return;
    saveUnsavedBufferedScreenshots();
  }

  static function getCurrentState():FlxState
  {
    var state:FlxState = FlxG.state;
    while (state.subState != null)
    {
      state = state.subState;
    }
    return state;
  }

  static function getScreenshotPath():String
  {
    return '$SCREENSHOT_FOLDER/';
  }

  static function makeScreenshotPath():Void
  {
    FileUtil.createDirIfNotExists(SCREENSHOT_FOLDER);
  }

  /**
   * Convert a Bitmap to a PNG ByteArray to save to a file.
   */
  function encode(bitmap:Bitmap):ByteArray
  {
    var compressor:PNGEncoderOptions = new PNGEncoderOptions();
    return bitmap.bitmapData.encode(bitmap.bitmapData.rect, compressor);
  }

  var previousScreenshotName:Null<String> = null;
  var previousScreenshotCopyNum:Int = 0;

  /**
   * Save the generated bitmap to a file.
   * @param bitmap The bitmap to save.
   * @param targetPath The name of the screenshot.
   * @param screenShotNum Used for the delay save option, to space out the saving of the images.
   * @param delaySave If true, the image gets saved with the screenShotNum as the delay.
   */
  function saveScreenshot(bitmap:Bitmap, targetPath = "image", screenShotNum:Int = 0, delaySave:Bool = true):Void
  {
    makeScreenshotPath();
    // Check that we're not overriding a previous image, and keep making a unique path until we can
    if (previousScreenshotName != targetPath && previousScreenshotName != (targetPath + ' (${previousScreenshotCopyNum})'))
    {
      previousScreenshotName = targetPath;
      targetPath = getScreenshotPath() + targetPath + '.png';
      previousScreenshotCopyNum = 2;
    }
    else
    {
      var newTargetPath:String = targetPath + ' (${previousScreenshotCopyNum})';
      while (previousScreenshotName == newTargetPath)
      {
        previousScreenshotCopyNum++;
        newTargetPath = targetPath + ' (${previousScreenshotCopyNum})';
      }
      previousScreenshotName = newTargetPath;
      targetPath = getScreenshotPath() + newTargetPath + '.png';
    }

    // TODO: Make screenshot saving work on browser.
    // Maybe save the images into a buffer that you can download as a zip or something? That'd work
    // Shouldn't be too hard to do something similar to the chart editor saving

    if (delaySave)
    { // Save the images with a delay (a timer)
      new FlxTimer().start(screenShotNum, function(_) {
        var pngData:ByteArray = encode(bitmap);

        if (pngData == null)
        {
          trace(' WARNING '.bold().bg_yellow() + ' Failed to encode PNG data');
          previousScreenshotName = null;
          // Just in case
          unsavedScreenshotBuffer.shift();
          unsavedScreenshotNameBuffer.shift();
          return;
        }
        else
        {
          trace('Saving screenshot to: ' + targetPath);
          FileUtil.writeBytesToPath(targetPath, pngData);
          // Remove the screenshot from the unsaved buffer because we literally just saved it
          unsavedScreenshotBuffer.shift();
          unsavedScreenshotNameBuffer.shift();
          if (Preferences.previewOnSave) showFancyPreview(bitmap); // Only show the preview after a screenshot is saved
        }
      });
    }
    else // Save the screenshot immediately
    {
      var pngData:ByteArray = encode(bitmap);

      if (pngData == null)
      {
        trace(' WARNING '.bold().bg_yellow() + ' Failed to encode PNG data');
        previousScreenshotName = null;
        return;
      }
      else
      {
        trace('Saving screenshot to: ' + targetPath);
        FileUtil.writeBytesToPath(targetPath, pngData);
        if (Preferences.previewOnSave) showFancyPreview(bitmap); // Only show the preview after a screenshot is saved
      }
    }
  }

  // I' m very happy with this code, all of it just works
  function saveBufferedScreenshots(screenshots:Array<Bitmap>, screenshotNames:Array<String>):Void
  {
    trace('Saving screenshot buffer');
    var i:Int = 0;

    asyncLoop = new FlxAsyncLoop(screenshots.length, () -> {
      if (screenshots[i] != null)
      {
        saveScreenshot(screenshots[i], screenshotNames[i], i);
      }
      i++;
    }, 1);
    getCurrentState().add(asyncLoop);
    if (!Preferences.flashingLights && !Preferences.previewOnSave)
    {
      showFancyPreview(screenshots[screenshots.length - 1]); // show the preview for the last screenshot
    }
  }

  /**
   * Similar to the above function, but cancels the tweens, undos the mouse
   * and doesn't have the async loop because this is called before the state changes
   */
  function saveUnsavedBufferedScreenshots():Void
  {
    stateChanging = true;
    // Cancel the tweens of the capture feedback if they're running
    if (flashSprite.alpha != 0 || previewSprite.alpha != 0)
    {
      for (sprite in [flashSprite, previewSprite])
      {
        FlxTween.cancelTweensOf(sprite);
        sprite.alpha = 0;
      }
    }

    // Undo the mouse stuff - we don't know what the next state will do with it
    if (wasMouseShown && FlxG.mouse.visible)
    {
      wasMouseShown = false;
      Cursor.hide();
    }
    else if (wasMouseHidden && !FlxG.mouse.visible)
    {
      wasMouseHidden = false;
      Cursor.show();
    }

    if (unsavedScreenshotBuffer[0] == null) return;
    // There's unsaved screenshots, let's save them! (haha, get it?)

    trace('Saving unsaved screenshots in buffer!');

    for (i in 0...unsavedScreenshotBuffer.length)
    {
      if (unsavedScreenshotBuffer[i] != null) saveScreenshot(unsavedScreenshotBuffer[i], unsavedScreenshotNameBuffer[i], i, false);
    }

    unsavedScreenshotBuffer = [];
    unsavedScreenshotNameBuffer = [];
  }

  function postStateSwitch():Void
  {
    stateChanging = false;
    screenshotBeingSpammed = false;
    FlxG.stage.removeChild(previewSprite);
  }

  override public function destroy():Void
  {
    if (instance == this) _instance = null;

    if (FlxG.plugins.list.contains(this)) FlxG.plugins.remove(this);

    FlxG.signals.gameResized.remove(this.resizeBitmap);
    FlxG.signals.preStateSwitch.remove(this.saveUnsavedBufferedScreenshots);
    FlxG.signals.postStateSwitch.remove(this.postStateSwitch);
    FlxG.stage.removeChild(previewSprite);
    FlxG.stage.removeChild(flashSprite);
    WindowUtil.windowExit.remove(onWindowClose);
    CrashHandler.errorSignal.remove(onWindowCrash);
    CrashHandler.criticalErrorSignal.remove(onWindowCrash);

    super.destroy();

    @:privateAccess
    for (parent in [flashSprite, previewSprite])
    {
      if (parent == null) continue;
      for (child in parent.__children)
      {
        parent.removeChild(child);
      }
    }

    // flashSprite = null
    // flashBitmap = null
    // previewSprite = null
    // shotPreviewBitmap = null
    // outlineBitmap = null
  }
}
