import * as THREE from "three";
// @ts-ignore
import * as CANNON from "cannon";

import { CameraOperator } from "../core/CameraOperator";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass";
import { ShaderPass } from "three/examples/jsm/postprocessing/ShaderPass";
import { FXAAShader } from "three/examples/jsm/shaders/FXAAShader";

import { Detector } from "../../lib/utils/Detector";
import { Stats } from "../../lib/utils/Stats";
import * as _ from "lodash";
import { CannonDebugRenderer } from "../../lib/cannon/CannonDebugRenderer";
import { InputManager } from "../core/InputManager";
import * as Utils from "../core/FunctionLibrary";
import { LoadingManager } from "../core/LoadingManager";
import { IWorldEntity } from "../interfaces/IWorldEntity";
import { IUpdatable } from "../interfaces/IUpdatable";
import { Character } from "../characters/Character";
import { CollisionGroups } from "../enums/CollisionGroups";
import { BoxCollider } from "../physics/colliders/BoxCollider";
import { TrimeshCollider } from "../physics/colliders/TrimeshCollider";
import { Item } from "../items/Item";
import { Bullet } from "../characters/Bullet";
import { Scenario } from "./Scenario";
import { Sky } from "./Sky";
import { IEventType } from "../interfaces/IEventType";
import { EventType } from "../enums/EventType";
import {
  ModelProps,
  MapDataType,
  MapType,
  MATEMATROLII_EVENT,
} from "../enums/WorldType";
import { IWorldOptions, IWorldParams } from "../interfaces/IWorldSettings";

export class World {
  public cannonDebugRenderer: CannonDebugRenderer;
  public renderer: THREE.WebGLRenderer;
  public camera: THREE.PerspectiveCamera;
  public composer: any;
  public stats: Stats;
  public graphicsWorld: THREE.Scene;
  public sky: Sky;
  public physicsWorld: CANNON.World;
  public parallelPairs: any[];
  public physicsFrameRate: number;
  public physicsFrameTime: number;
  public physicsMaxPrediction: number;
  public clock: THREE.Clock;
  public renderDelta: number;
  public logicDelta: number;
  public requestDelta: number;
  public sinceLastFrame: number;
  public justRendered: boolean;
  public params: any;
  public inputManager: InputManager;
  public cameraOperator: CameraOperator;
  public timeScaleTarget: number = 1;
  public scenarios: Scenario[] = [];
  public characters: Character[] = [];
  public characterNames: {
    [key: string]: Character;
  } = {};
  public items: Item[] = [];
  public bullets: Bullet[] = [];
  public updatables: IUpdatable[] = [];
  public worldOptions: IWorldOptions = null;
  public worldParams: IWorldParams = null;
  private lastScenarioID: string;
  private animationFrame = null;
  private pixelRatio: number = null;
  private fxaaPass: ShaderPass = null;

  constructor(
    worldOptions: IWorldOptions,
    worldParams: IWorldParams,
    canvas?: HTMLCanvasElement
  ) {
    const scope = this;
    // WebGL not supported
    if (!Detector.webgl) {
      console.log(
        "This browser doesn't seem to have the required WebGL capabilities"
      );
    }

    this.worldOptions = worldOptions;
    this.worldParams = worldParams;
    // Renderer
    this.renderer = new THREE.WebGLRenderer({
      powerPreference: "high-performance",
      antialias: false,
      alpha: false,
      stencil: false,
      canvas,
    });
    this.renderer.setPixelRatio(window.devicePixelRatio);
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
    this.renderer.toneMappingExposure = 1.0;
    this.renderer.shadowMap.enabled = true;
    this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    this.renderer.sortObjects = false;
    //this.renderer.physicallyCorrectLights = true;

    // Canvas
    if (!canvas) {
      document.body.appendChild(this.renderer.domElement);
    }

    // Auto window resize
    window.addEventListener("resize", this.onWindowResize.bind(this), false);

    // Three.js scene
    this.graphicsWorld = new THREE.Scene();
    this.camera = new THREE.PerspectiveCamera(
      80,
      window.innerWidth / window.innerHeight,
      0.1,
      300
    );

    // Passes
    let renderPass = new RenderPass(this.graphicsWorld, this.camera);
    this.fxaaPass = new ShaderPass(FXAAShader);

    // FXAA
    this.pixelRatio = this.renderer.getPixelRatio();
    this.fxaaPass.material["uniforms"].resolution.value.x =
      1 / (window.innerWidth * this.pixelRatio);
    this.fxaaPass.material["uniforms"].resolution.value.y =
      1 / (window.innerHeight * this.pixelRatio);

    // Composer
    this.composer = new EffectComposer(this.renderer);
    this.composer.addPass(renderPass);
    this.composer.addPass(this.fxaaPass);

    // Physics
    this.physicsWorld = new CANNON.World();
    this.physicsWorld.gravity.set(0, -9.81, 0);
    this.physicsWorld.broadphase = new CANNON.SAPBroadphase(this.physicsWorld);
    this.physicsWorld.solver.iterations = 10;
    this.physicsWorld.allowSleep = true;

    this.parallelPairs = [];
    this.physicsFrameRate = 60;
    this.physicsFrameTime = 1 / this.physicsFrameRate;
    this.physicsMaxPrediction = this.physicsFrameRate;

    // RenderLoop
    this.clock = new THREE.Clock();
    this.renderDelta = 0;
    this.logicDelta = 0;
    this.sinceLastFrame = 0;
    this.justRendered = false;

    // Stats (FPS, Frame time, Memory)
    this.stats = Stats();

    this.params = _.merge(
      {
        Pointer_Lock: true, // true - false
        Mouse_Sensitivity: 0.3, // 0 - 1
        Time_Scale: 1, // 0 - 1
        Shadows: true, // true - false
        FXAA: false, // true - false
        Debug_Physics: false, // true - false
        Debug_FPS: false, // true - false
        Sun_Elevation: 50, // 0 - 180
        Sun_Rotation: 150, // 0 - 360
      },
      worldParams
    );

    // Initialization
    this.inputManager = new InputManager(this, this.renderer.domElement);
    this.cameraOperator = new CameraOperator(
      this,
      this.camera,
      this.params.Mouse_Sensitivity
    );
    this.sky = new Sky(this);
    this.sky.phi = this.params.Sun_Elevation;
    this.sky.theta = this.params.Sun_Rotation;

    if (this.params.Shadows) {
      this.sky.csm.lights.forEach((light) => {
        light.castShadow = true;
      });
    } else {
      this.sky.csm.lights.forEach((light) => {
        light.castShadow = false;
      });
    }

    if (this.params.Debug_Physics) {
      this.cannonDebugRenderer = new CannonDebugRenderer(
        this.graphicsWorld,
        this.physicsWorld
      );
    }

    if (this.params.Debug_FPS) {
      document.getElementById("statsBox").style.display = "block";
    }

    // Load scene if path is supplied
    if (worldOptions?.world !== undefined) {
      let loadingManager = new LoadingManager(this);
      loadingManager.onFinishedCallback = () => {
        this.update(1, 1);
        this.setTimeScale(1);
        this.triggerEvent({
          type: EventType.INITIALISE,
          targets: [],
        });
      };
      loadingManager.loadGLTF(worldOptions?.world, (gltf) => {
        this.loadScene(loadingManager, gltf);
      });
    } else {
      this.triggerEvent({
        type: EventType.MISSING_WORLD,
        targets: [],
      });
    }

    this.render(this);
  }

  private onWindowResize(): void {
    this.camera.aspect = window.innerWidth / window.innerHeight;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    this.fxaaPass.uniforms["resolution"].value.set(
      1 / (window.innerWidth * this.pixelRatio),
      1 / (window.innerHeight * this.pixelRatio)
    );
    this.composer.setSize(
      window.innerWidth * this.pixelRatio,
      window.innerHeight * this.pixelRatio
    );
  }

  // Update
  // Handles all logic updates.
  public update(timeStep: number, unscaledTimeStep: number): void {
    this.updatePhysics(timeStep);

    // Update registred objects
    this.updatables.forEach((entity) => {
      if (entity.update) {
        entity.update(timeStep, unscaledTimeStep);
      }
    });

    // Lerp time scale
    this.params.Time_Scale = THREE.MathUtils.lerp(
      this.params.Time_Scale,
      this.timeScaleTarget,
      0.2
    );
    if (this.params.Debug_Physics) this.cannonDebugRenderer.update();
  }

  public updatePhysics(timeStep: number): void {
    // Step the physics world
    this.physicsWorld.step(this.physicsFrameTime, timeStep);

    this.characters.forEach((char) => {
      if (this.isOutOfBounds(char.characterCapsule.body.position)) {
        this.outOfBoundsRespawn(char.characterCapsule.body);
      }
    });
  }

  public isOutOfBounds(position: CANNON.Vec3): boolean {
    const inside =
      position.x > -211.882 &&
      position.x < 211.882 &&
      position.z > -169.098 &&
      position.z < 153.232 &&
      position.y > 0;
    const belowSeaLevel = position.y < 0;
    const isOFB = !inside && belowSeaLevel;
    return isOFB;
  }

  public outOfBoundsRespawn(body: CANNON.Body, position?: CANNON.Vec3): void {
    let newPos = position || new CANNON.Vec3(0, 16, 0);
    let newQuat = new CANNON.Quaternion(0, 0, 0, 1);

    body.position.copy(newPos);
    body.interpolatedPosition.copy(newPos);
    body.quaternion.copy(newQuat);
    body.interpolatedQuaternion.copy(newQuat);
    body.velocity.setZero();
    body.angularVelocity.setZero();
  }

  public stopRender() {
    window.cancelAnimationFrame(this.animationFrame);
    this.animationFrame = null;
  }

  /**
   * Rendering loop.
   * Implements fps limiter and frame-skipping
   * Calls world's "update" function before rendering.
   * @param {World} world
   */
  public render(world: World): void {
    this.requestDelta = this.clock.getDelta();

    this.animationFrame = requestAnimationFrame(() => {
      world.render(world);
    });

    // Getting timeStep
    let unscaledTimeStep =
      this.requestDelta + this.renderDelta + this.logicDelta;
    let timeStep = unscaledTimeStep * this.params.Time_Scale;
    timeStep = Math.min(timeStep, 1 / 30); // min 30 fps

    // Logic
    world.update(timeStep, unscaledTimeStep);

    // Measuring logic time
    this.logicDelta = this.clock.getDelta();

    // Frame limiting
    let interval = 1 / 60;
    this.sinceLastFrame +=
      this.requestDelta + this.renderDelta + this.logicDelta;
    this.sinceLastFrame %= interval;

    // Stats end
    this.stats.end();
    this.stats.begin();

    // Actual rendering with a FXAA ON/OFF switch
    if (this.params.FXAA) this.composer.render();
    else this.renderer.render(this.graphicsWorld, this.camera);

    // Measuring render time
    this.renderDelta = this.clock.getDelta();
  }

  public setTimeScale(value: number): void {
    this.params.Time_Scale = value;
    this.timeScaleTarget = value;
  }

  public add(worldEntity: IWorldEntity): void {
    worldEntity.addToWorld(this);
    this.registerUpdatable(worldEntity);
  }

  public registerUpdatable(registree: IUpdatable): void {
    this.updatables.push(registree);
    this.updatables.sort((a, b) => (a.updateOrder > b.updateOrder ? 1 : -1));
  }

  public remove(worldEntity: IWorldEntity): void {
    worldEntity.removeFromWorld(this);
    this.unregisterUpdatable(worldEntity);
  }

  public unregisterUpdatable(registree: IUpdatable): void {
    _.pull(this.updatables, registree);
  }

  public triggerEvent(data: { type: IEventType; targets: string[] }) {
    const event = new CustomEvent(MATEMATROLII_EVENT, { detail: data });
    window.document.dispatchEvent(event);
  }

  public loadScene(loadingManager: LoadingManager, gltf: any): void {
    gltf.scene.traverse((child) => {
      if (child.hasOwnProperty(ModelProps.USER_DATA)) {
        const userData = child[ModelProps.USER_DATA];
        if (child.type === "Mesh") {
          Utils.setupMeshProperties(child, this.worldOptions);
          this.sky.csm.setupMaterial(child.material);
        }

        if (userData.hasOwnProperty(MapDataType.DATA_PROP)) {
          if (userData[MapDataType.DATA_PROP] === MapDataType.PHYSICS) {
            if (userData.hasOwnProperty(MapType.TYPE_PROP)) {
              // Convex doesn't work! Stick to boxes!
              if (userData.type === MapType.BOX) {
                let phys = new BoxCollider({
                  size: new THREE.Vector3(
                    child.scale.x,
                    child.scale.y,
                    child.scale.z
                  ),
                });
                phys.body.position.copy(Utils.cannonVector(child.position));
                phys.body.quaternion.copy(Utils.cannonQuat(child.quaternion));
                phys.body.computeAABB();

                phys.body.shapes.forEach((shape) => {
                  shape.collisionFilterMask = ~CollisionGroups.TrimeshColliders;
                });

                this.physicsWorld.addBody(phys.body);
                phys = undefined;
              } else if (userData.type === MapType.TRIMESH) {
                let phys = new TrimeshCollider(child, {});
                this.physicsWorld.addBody(phys.body);
                phys.body.sleep();
                phys = undefined;
              }
            }
          }

          if (userData[MapDataType.DATA_PROP] === MapDataType.SCENARIO) {
            this.scenarios.push(new Scenario(child, this));
          }
        }
      }
    });

    this.graphicsWorld.add(gltf.scene);

    // Launch default scenario
    let defaultScenarioID: string;
    for (const scenario of this.scenarios) {
      if (scenario.id === this.worldOptions.scenario) {
        defaultScenarioID = scenario.id;
        break;
      }
    }
    if (defaultScenarioID !== undefined)
      this.launchScenario(defaultScenarioID, loadingManager);
  }

  public launchScenario(
    scenarioID: string,
    loadingManager?: LoadingManager
  ): void {
    this.lastScenarioID = scenarioID;
    this.clearEntities();

    // Launch default scenario
    if (!loadingManager) loadingManager = new LoadingManager(this);
    for (const scenario of this.scenarios) {
      if (scenario.id === scenarioID) {
        scenario.launch(loadingManager, this);
      }
    }
  }

  public restartScenario(): void {
    if (this.lastScenarioID !== undefined) {
      //document.exitPointerLock();
      this.launchScenario(this.lastScenarioID);
    } else {
      console.warn("Can't restart scenario. Last scenarioID is undefined.");
    }
  }

  public clearEntities(): void {
    for (let i = 0; i < this.characters.length; i++) {
      this.remove(this.characters[i]);
      i--;
    }
    this.characters = [];

    for (let key in this.characterNames) {
      this.characterNames[key] = undefined;
    }
    this.characterNames = {};

    for (let i = 0; i < this.items.length; i++) {
      this.remove(this.items[i]);
      i--;
    }
    this.items = [];

    for (let i = 0; i < this.bullets.length; i++) {
      this.remove(this.bullets[i]);
      i--;
    }
    this.bullets = [];
  }

  public destroy(): void {
    this.clearEntities();
    window.cancelAnimationFrame(this.animationFrame);
    window.removeEventListener("resize", this.onWindowResize);
    if (this.graphicsWorld) {
      while (this.graphicsWorld.children.length > 0) {
        this.graphicsWorld.remove(this.graphicsWorld.children[0]);
      }
      this.graphicsWorld = undefined;
    }
    if (this.renderer) {
      this.renderer.dispose();
      this.renderer.forceContextLoss();
      this.renderer = undefined;
    }
    this.clock?.stop();
    this.clock = undefined;
    this.camera?.remove();
    this.camera = undefined;
    this.inputManager.destroy();
    this.inputManager = undefined;
    this.physicsWorld = undefined;
    for (let i = 0; i < this.scenarios.length; i++) {
      this.scenarios[i].destroy();
    }
    this.scenarios = undefined;
    this.cannonDebugRenderer = undefined;
    this.composer = undefined;
    this.stats = undefined;
    this.sky.destroy();
    this.sky = undefined;
    this.parallelPairs = undefined;
    this.physicsFrameRate = undefined;
    this.physicsFrameTime = undefined;
    this.physicsMaxPrediction = undefined;
    this.renderDelta = undefined;
    this.logicDelta = undefined;
    this.requestDelta = undefined;
    this.sinceLastFrame = undefined;
    this.justRendered = undefined;
    this.params = undefined;
    this.cameraOperator = undefined;
    this.timeScaleTarget = undefined;
    this.characters = undefined;
    this.characterNames = undefined;
    this.items = undefined;
    this.bullets = undefined;
    this.updatables = undefined;
    this.worldOptions = undefined;
    this.worldParams = undefined;
    this.lastScenarioID = undefined;
    this.animationFrame = undefined;
    this.pixelRatio = undefined;
    this.fxaaPass = undefined;
  }

  public scrollTheTimeScale(scrollAmount: number): void {
    // Changing time scale with scroll wheel
    const timeScaleBottomLimit = 0.003;
    const timeScaleChangeSpeed = 1.3;

    if (scrollAmount > 0) {
      this.timeScaleTarget /= timeScaleChangeSpeed;
      if (this.timeScaleTarget < timeScaleBottomLimit) this.timeScaleTarget = 0;
    } else {
      this.timeScaleTarget *= timeScaleChangeSpeed;
      if (this.timeScaleTarget < timeScaleBottomLimit)
        this.timeScaleTarget = timeScaleBottomLimit;
      this.timeScaleTarget = Math.min(this.timeScaleTarget, 1);
    }
  }
}
