import autobind from 'autobind-decorator';
import { v4 as uuid } from 'uuid';
import * as THREE from 'three';
import { GLTFLoader, GLTF } from 'three/examples/jsm/loaders/GLTFLoader';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
import { BloomPass } from 'three/examples/jsm/postprocessing/BloomPass.js';
import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader.js';
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
import { Furniture, RoomInfo } from './furniture';
import { query as urlQuery } from '../../../prelude/url';
const furnitureDefs = require('./furnitures.json5');

THREE.ImageUtils.crossOrigin = '';

type Options = {
	graphicsQuality: Room['graphicsQuality'];
	onChangeSelect: Room['onChangeSelect'];
	useOrthographicCamera: boolean;
};

/**
 * MisskeyRoom Core Engine
 */
export class Room {
	private clock: THREE.Clock;
	private scene: THREE.Scene;
	private renderer: THREE.WebGLRenderer;
	private camera: THREE.PerspectiveCamera | THREE.OrthographicCamera;
	private controls: OrbitControls;
	private composer: EffectComposer;
	private mixers: THREE.AnimationMixer[] = [];
	private furnitureControl: TransformControls;
	private roomInfo: RoomInfo;
	private graphicsQuality: 'cheep' | 'low' | 'medium' | 'high' | 'ultra';
	private roomObj: THREE.Object3D;
	private objects: THREE.Object3D[] = [];
	private selectedObject: THREE.Object3D = null;
	private onChangeSelect: Function;
	private isTransformMode = false;
	private renderFrameRequestId: number;

	private get canvas(): HTMLCanvasElement {
		return this.renderer.domElement;
	}

	private get furnitures(): Furniture[] {
		return this.roomInfo.furnitures;
	}

	private set furnitures(furnitures: Furniture[]) {
		this.roomInfo.furnitures = furnitures;
	}

	private get enableShadow() {
		return this.graphicsQuality != 'cheep';
	}

	private get usePostFXs() {
		return this.graphicsQuality !== 'cheep' && this.graphicsQuality !== 'low';
	}

	private get shadowQuality() {
		return (
			this.graphicsQuality === 'ultra' ? 16384 :
			this.graphicsQuality === 'high' ? 8192 :
			this.graphicsQuality === 'medium' ? 4096 :
			this.graphicsQuality === 'low' ? 1024 :
			0); // cheep
	}

	constructor(user, isMyRoom, roomInfo: RoomInfo, container: Element, options: Options) {
		this.roomInfo = roomInfo;
		this.graphicsQuality = options.graphicsQuality;
		this.onChangeSelect = options.onChangeSelect;

		this.clock = new THREE.Clock(true);

		//#region Init a scene
		this.scene = new THREE.Scene();

		const width = container.clientWidth;
		const height = container.clientHeight;

		//#region Init a renderer
		this.renderer = new THREE.WebGLRenderer({
			antialias: false,
			stencil: false,
			alpha: false,
			powerPreference:
				this.graphicsQuality === 'ultra' ? 'high-performance' :
				this.graphicsQuality === 'high' ? 'high-performance' :
				this.graphicsQuality === 'medium' ? 'default' :
				this.graphicsQuality === 'low' ? 'low-power' :
				'low-power' // cheep
		});

		this.renderer.setPixelRatio(window.devicePixelRatio);
		this.renderer.setSize(width, height);
		this.renderer.autoClear = false;
		this.renderer.setClearColor(new THREE.Color(0x051f2d));
		this.renderer.shadowMap.enabled = this.enableShadow;
		this.renderer.shadowMap.type =
			this.graphicsQuality === 'ultra' ? THREE.PCFSoftShadowMap :
			this.graphicsQuality === 'high' ? THREE.PCFSoftShadowMap :
			this.graphicsQuality === 'medium' ? THREE.PCFShadowMap :
			this.graphicsQuality === 'low' ? THREE.BasicShadowMap :
			THREE.BasicShadowMap; // cheep

		container.insertBefore(this.canvas, container.firstChild);
		//#endregion

		//#region Init a camera
		this.camera = options.useOrthographicCamera
			? new THREE.OrthographicCamera(
				width / - 2, width / 2, height / 2, height / - 2, -10, 10)
			: new THREE.PerspectiveCamera(45, width / height);

		if (options.useOrthographicCamera) {
			this.camera.position.x = 2;
			this.camera.position.y = 2;
			this.camera.position.z = 2;
			this.camera.zoom = 100;
			this.camera.updateProjectionMatrix();
		} else {
			this.camera.position.x = 5;
			this.camera.position.y = 2;
			this.camera.position.z = 5;
		}

		this.scene.add(this.camera);
		//#endregion

		//#region AmbientLight
		const ambientLight = new THREE.AmbientLight(0xffffff, 1);
		this.scene.add(ambientLight);
		//#endregion

		if (this.graphicsQuality !== 'cheep') {
			//#region Room light
			const roomLight = new THREE.SpotLight(0xffffff, 0.1);

			roomLight.position.set(0, 8, 0);
			roomLight.castShadow = this.enableShadow;
			roomLight.shadow.bias = -0.0001;
			roomLight.shadow.mapSize.width = this.shadowQuality;
			roomLight.shadow.mapSize.height = this.shadowQuality;
			roomLight.shadow.camera.near = 0.1;
			roomLight.shadow.camera.far = 9;
			roomLight.shadow.camera.fov = 45;

			this.scene.add(roomLight);
			//#endregion
		}

		//#region Out light
		const outLight1 = new THREE.SpotLight(0xffffff, 0.4);
		outLight1.position.set(9, 3, -2);
		outLight1.castShadow = this.enableShadow;
		outLight1.shadow.bias = -0.001; // アクネ、アーチファクト対策 その代わりピーターパンが発生する可能性がある
		outLight1.shadow.mapSize.width = this.shadowQuality;
		outLight1.shadow.mapSize.height = this.shadowQuality;
		outLight1.shadow.camera.near = 6;
		outLight1.shadow.camera.far = 15;
		outLight1.shadow.camera.fov = 45;
		this.scene.add(outLight1);

		const outLight2 = new THREE.SpotLight(0xffffff, 0.2);
		outLight2.position.set(-2, 3, 9);
		outLight2.castShadow = false;
		outLight2.shadow.bias = -0.001; // アクネ、アーチファクト対策 その代わりピーターパンが発生する可能性がある
		outLight2.shadow.camera.near = 6;
		outLight2.shadow.camera.far = 15;
		outLight2.shadow.camera.fov = 45;
		this.scene.add(outLight2);
		//#endregion

		//#region Init a controller
		this.controls = new OrbitControls(this.camera, this.canvas);

		this.controls.target.set(0, 1, 0);
		this.controls.enableZoom = true;
		this.controls.enablePan = isMyRoom;
		this.controls.minPolarAngle = 0;
		this.controls.maxPolarAngle = Math.PI / 2;
		this.controls.minAzimuthAngle = 0;
		this.controls.maxAzimuthAngle = Math.PI / 2;
		this.controls.enableDamping = true;
		this.controls.dampingFactor = 0.2;
		//#endregion

		//#region POST FXs
		if (!this.usePostFXs) {
			this.composer = null;
		} else {
			const renderTarget = new THREE.WebGLRenderTarget(width, height, {
				minFilter: THREE.LinearFilter,
				magFilter: THREE.LinearFilter,
				format: THREE.RGBFormat,
				stencilBuffer: false,
			});

			const fxaa = new ShaderPass(FXAAShader);
			fxaa.uniforms['resolution'].value = new THREE.Vector2(1 / width, 1 / height);
			fxaa.renderToScreen = true;

			this.composer = new EffectComposer(this.renderer, renderTarget);
			this.composer.addPass(new RenderPass(this.scene, this.camera));
			if (this.graphicsQuality === 'ultra') {
				this.composer.addPass(new BloomPass(0.25, 30, 128.0, 512));
			}
			this.composer.addPass(fxaa);
		}
		//#endregion
		//#endregion

		//#region Label
		//#region Avatar
		const avatarUrl = `/proxy/?${urlQuery({ url: user.avatarUrl })}`;

		const textureLoader = new THREE.TextureLoader();
		textureLoader.crossOrigin = 'anonymous';

		const iconTexture = textureLoader.load(avatarUrl);
		iconTexture.wrapS = THREE.RepeatWrapping;
		iconTexture.wrapT = THREE.RepeatWrapping;
		iconTexture.anisotropy = 16;

		const avatarMaterial = new THREE.MeshBasicMaterial({
			map: iconTexture,
			side: THREE.DoubleSide,
			alphaTest: 0.5
		});

		const iconGeometry = new THREE.PlaneGeometry(1, 1);

		const avatarObject = new THREE.Mesh(iconGeometry, avatarMaterial);
		avatarObject.position.set(-3, 2.5, 2);
		avatarObject.rotation.y = Math.PI / 2;
		avatarObject.castShadow = false;

		this.scene.add(avatarObject);
		//#endregion

		//#region Username
		const name = user.username;

		new THREE.FontLoader().load('/assets/fonts/helvetiker_regular.typeface.json', font => {
			const nameGeometry = new THREE.TextGeometry(name, {
				size: 0.5,
				height: 0,
				curveSegments: 8,
				font: font,
				bevelThickness: 0,
				bevelSize: 0,
				bevelEnabled: false
			});

			const nameMaterial = new THREE.MeshLambertMaterial({
				color: 0xffffff
			});

			const nameObject = new THREE.Mesh(nameGeometry, nameMaterial);
			nameObject.position.set(-3, 2.25, 1.25);
			nameObject.rotation.y = Math.PI / 2;
			nameObject.castShadow = false;

			this.scene.add(nameObject);
		});
		//#endregion
		//#endregion

		//#region Interaction
		if (isMyRoom) {
			this.furnitureControl = new TransformControls(this.camera, this.canvas);
			this.scene.add(this.furnitureControl);

			// Hover highlight
			this.canvas.onmousemove = this.onmousemove;

			// Click
			this.canvas.onmousedown = this.onmousedown;
		}
		//#endregion

		//#region Init room
		this.loadRoom();
		//#endregion

		//#region Load furnitures
		for (const furniture of this.furnitures) {
			this.loadFurniture(furniture).then(obj => {
				this.scene.add(obj.scene);
				this.objects.push(obj.scene);
			});
		}
		//#endregion

		// Start render
		if (this.usePostFXs) {
			this.renderWithPostFXs();
		} else {
			this.renderWithoutPostFXs();
		}
	}

	@autobind
	private renderWithoutPostFXs() {
		this.renderFrameRequestId =
			window.requestAnimationFrame(this.renderWithoutPostFXs);

		// Update animations
		const clock = this.clock.getDelta();
		for (const mixer of this.mixers) {
			mixer.update(clock);
		}

		this.controls.update();
		this.renderer.render(this.scene, this.camera);
	}

	@autobind
	private renderWithPostFXs() {
		this.renderFrameRequestId =
			window.requestAnimationFrame(this.renderWithPostFXs);

		// Update animations
		const clock = this.clock.getDelta();
		for (const mixer of this.mixers) {
			mixer.update(clock);
		}

		this.controls.update();
		this.renderer.clear();
		this.composer.render();
	}

	@autobind
	private loadRoom() {
		const type = this.roomInfo.roomType;
		new GLTFLoader().load(`/static-assets/client/room/rooms/${type}/${type}.glb`, gltf => {
			gltf.scene.traverse(child => {
				if (!(child instanceof THREE.Mesh)) return;

				child.receiveShadow = this.enableShadow;

				child.material = new THREE.MeshLambertMaterial({
					color: (child.material as THREE.MeshStandardMaterial).color,
					map: (child.material as THREE.MeshStandardMaterial).map,
					name: (child.material as THREE.MeshStandardMaterial).name,
				});

				// 異方性フィルタリング
				if ((child.material as THREE.MeshLambertMaterial).map && this.graphicsQuality !== 'cheep') {
					(child.material as THREE.MeshLambertMaterial).map.minFilter = THREE.LinearMipMapLinearFilter;
					(child.material as THREE.MeshLambertMaterial).map.magFilter = THREE.LinearMipMapLinearFilter;
					(child.material as THREE.MeshLambertMaterial).map.anisotropy = 8;
				}
			});

			gltf.scene.position.set(0, 0, 0);

			this.scene.add(gltf.scene);
			this.roomObj = gltf.scene;
			if (this.roomInfo.roomType === 'default') {
				this.applyCarpetColor();
			}
		});
	}

	@autobind
	private loadFurniture(furniture: Furniture) {
		const def = furnitureDefs.find(d => d.id === furniture.type);
		return new Promise<GLTF>((res, rej) => {
			const loader = new GLTFLoader();
			loader.load(`/static-assets/client/room/furnitures/${furniture.type}/${furniture.type}.glb`, gltf => {
				const model = gltf.scene;

				// Load animation
				if (gltf.animations.length > 0) {
					const mixer = new THREE.AnimationMixer(model);
					this.mixers.push(mixer);
					for (const clip of gltf.animations) {
						mixer.clipAction(clip).play();
					}
				}

				model.name = furniture.id;
				model.position.x = furniture.position.x;
				model.position.y = furniture.position.y;
				model.position.z = furniture.position.z;
				model.rotation.x = furniture.rotation.x;
				model.rotation.y = furniture.rotation.y;
				model.rotation.z = furniture.rotation.z;

				model.traverse(child => {
					if (!(child instanceof THREE.Mesh)) return;
					child.castShadow = this.enableShadow;
					child.receiveShadow = this.enableShadow;
					(child.material as THREE.MeshStandardMaterial).metalness = 0;

					// 異方性フィルタリング
					if ((child.material as THREE.MeshStandardMaterial).map && this.graphicsQuality !== 'cheep') {
						(child.material as THREE.MeshStandardMaterial).map.minFilter = THREE.LinearMipMapLinearFilter;
						(child.material as THREE.MeshStandardMaterial).map.magFilter = THREE.LinearMipMapLinearFilter;
						(child.material as THREE.MeshStandardMaterial).map.anisotropy = 8;
					}
				});

				if (def.color) { // カスタムカラー
					this.applyCustomColor(model);
				}

				if (def.texture) { // カスタムテクスチャ
					this.applyCustomTexture(model);
				}

				res(gltf);
			}, null, rej);
		});
	}

	@autobind
	private applyCarpetColor() {
		this.roomObj.traverse(child => {
			if (!(child instanceof THREE.Mesh)) return;
			if (child.material &&
				(child.material as THREE.MeshStandardMaterial).name &&
				(child.material as THREE.MeshStandardMaterial).name === 'Carpet'
			) {
				const colorHex = parseInt(this.roomInfo.carpetColor.substr(1), 16);
				(child.material as THREE.MeshStandardMaterial).color.setHex(colorHex);
			}
		});
	}

	@autobind
	private applyCustomColor(model: THREE.Object3D) {
		const furniture = this.furnitures.find(furniture => furniture.id === model.name);
		const def = furnitureDefs.find(d => d.id === furniture.type);
		if (def.color == null) return;
		model.traverse(child => {
			if (!(child instanceof THREE.Mesh)) return;
			for (const t of Object.keys(def.color)) {
				if (!child.material ||
					!(child.material as THREE.MeshStandardMaterial).name ||
					(child.material as THREE.MeshStandardMaterial).name !== t
				) continue;

				const prop = def.color[t];
				const val = furniture.props ? furniture.props[prop] : undefined;

				if (val == null) continue;

				const colorHex = parseInt(val.substr(1), 16);
				(child.material as THREE.MeshStandardMaterial).color.setHex(colorHex);
			}
		});
	}

	@autobind
	private applyCustomTexture(model: THREE.Object3D) {
		const furniture = this.furnitures.find(furniture => furniture.id === model.name);
		const def = furnitureDefs.find(d => d.id === furniture.type);
		if (def.texture == null) return;

		model.traverse(child => {
			if (!(child instanceof THREE.Mesh)) return;
			for (const t of Object.keys(def.texture)) {
				if (child.name !== t) continue;

				const prop = def.texture[t].prop;
				const val = furniture.props ? furniture.props[prop] : undefined;

				if (val == null) continue;

				const canvas = document.createElement('canvas');
				canvas.height = 1024;
				canvas.width = 1024;

				child.material = new THREE.MeshLambertMaterial({
					emissive: 0x111111,
					side: THREE.DoubleSide,
					alphaTest: 0.5,
				});

				const img = new Image();
				img.crossOrigin = 'anonymous';
				img.onload = () => {
					const uvInfo = def.texture[t].uv;

					const ctx = canvas.getContext('2d');
					ctx.drawImage(img,
						0, 0, img.width, img.height,
						uvInfo.x, uvInfo.y, uvInfo.width, uvInfo.height);

					const texture = new THREE.Texture(canvas);
					texture.wrapS = THREE.RepeatWrapping;
					texture.wrapT = THREE.RepeatWrapping;
					texture.anisotropy = 16;
					texture.flipY = false;

					(child.material as THREE.MeshLambertMaterial).map = texture;
					(child.material as THREE.MeshLambertMaterial).needsUpdate = true;
					(child.material as THREE.MeshLambertMaterial).map.needsUpdate = true;
				};
				img.src = val;
			}
		});
	}

	@autobind
	private onmousemove(ev: MouseEvent) {
		if (this.isTransformMode) return;

		const rect = (ev.target as HTMLElement).getBoundingClientRect();
		const x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
		const y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
		const pos = new THREE.Vector2(x, y);

		this.camera.updateMatrixWorld();

		const raycaster = new THREE.Raycaster();
		raycaster.setFromCamera(pos, this.camera);

		const intersects = raycaster.intersectObjects(this.objects, true);

		for (const object of this.objects) {
			if (this.isSelectedObject(object)) continue;
			object.traverse(child => {
				if (child instanceof THREE.Mesh) {
					(child.material as THREE.MeshStandardMaterial).emissive.setHex(0x000000);
				}
			});
		}

		if (intersects.length > 0) {
			const intersected = this.getRoot(intersects[0].object);
			if (this.isSelectedObject(intersected)) return;
			intersected.traverse(child => {
				if (child instanceof THREE.Mesh) {
					(child.material as THREE.MeshStandardMaterial).emissive.setHex(0x191919);
				}
			});
		}
	}

	@autobind
	private onmousedown(ev: MouseEvent) {
		if (this.isTransformMode) return;
		if (ev.target !== this.canvas || ev.button !== 0) return;

		const rect = (ev.target as HTMLElement).getBoundingClientRect();
		const x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
		const y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
		const pos = new THREE.Vector2(x, y);

		this.camera.updateMatrixWorld();

		const raycaster = new THREE.Raycaster();
		raycaster.setFromCamera(pos, this.camera);

		const intersects = raycaster.intersectObjects(this.objects, true);

		for (const object of this.objects) {
			object.traverse(child => {
				if (child instanceof THREE.Mesh) {
					(child.material as THREE.MeshStandardMaterial).emissive.setHex(0x000000);
				}
			});
		}

		if (intersects.length > 0) {
			const selectedObj = this.getRoot(intersects[0].object);
			this.selectFurniture(selectedObj);
		} else {
			this.selectedObject = null;
			this.onChangeSelect(null);
		}
	}

	@autobind
	private getRoot(obj: THREE.Object3D): THREE.Object3D {
		let found = false;
		let x = obj.parent;
		while (!found) {
			if (x.parent.parent == null) {
				found = true;
			} else {
				x = x.parent;
			}
		}
		return x;
	}

	@autobind
	private isSelectedObject(obj: THREE.Object3D): boolean {
		if (this.selectedObject == null) {
			return false;
		} else {
			return obj.name === this.selectedObject.name;
		}
	}

	@autobind
	private selectFurniture(obj: THREE.Object3D) {
		this.selectedObject = obj;
		this.onChangeSelect(obj);
		obj.traverse(child => {
			if (child instanceof THREE.Mesh) {
				(child.material as THREE.MeshStandardMaterial).emissive.setHex(0xff0000);
			}
		});
	}

	/**
	 * 家具の移動/回転モードにします
	 * @param type 移動か回転か
	 */
	@autobind
	public enterTransformMode(type: 'translate' | 'rotate') {
		this.isTransformMode = true;
		this.furnitureControl.setMode(type);
		this.furnitureControl.attach(this.selectedObject);
		this.controls.enableRotate = false;
	}

	/**
	 * 家具の移動/回転モードを終了します
	 */
	@autobind
	public exitTransformMode() {
		this.isTransformMode = false;
		this.furnitureControl.detach();
		this.controls.enableRotate = true;
	}

	/**
	 * 家具プロパティを更新します
	 * @param key プロパティ名
	 * @param value 値
	 */
	@autobind
	public updateProp(key: string, value: any) {
		const furniture = this.furnitures.find(furniture => furniture.id === this.selectedObject.name);
		if (furniture.props == null) furniture.props = {};
		furniture.props[key] = value;
		this.applyCustomColor(this.selectedObject);
		this.applyCustomTexture(this.selectedObject);
	}

	/**
	 * 部屋に家具を追加します
	 * @param type 家具の種類
	 */
	@autobind
	public addFurniture(type: string) {
		const furniture = {
			id: uuid(),
			type: type,
			position: {
				x: 0,
				y: 0,
				z: 0,
			},
			rotation: {
				x: 0,
				y: 0,
				z: 0,
			},
		};

		this.furnitures.push(furniture);

		this.loadFurniture(furniture).then(obj => {
			this.scene.add(obj.scene);
			this.objects.push(obj.scene);
		});
	}

	/**
	 * 現在選択されている家具を部屋から削除します
	 */
	@autobind
	public removeFurniture() {
		this.exitTransformMode();
		const obj = this.selectedObject;
		this.scene.remove(obj);
		this.objects = this.objects.filter(object => object.name !== obj.name);
		this.furnitures = this.furnitures.filter(furniture => furniture.id !== obj.name);
		this.selectedObject = null;
		this.onChangeSelect(null);
	}

	/**
	 * 全ての家具を部屋から削除します
	 */
	@autobind
	public removeAllFurnitures() {
		this.exitTransformMode();
		for (const obj of this.objects) {
			this.scene.remove(obj);
		}
		this.objects = [];
		this.furnitures = [];
		this.selectedObject = null;
		this.onChangeSelect(null);
	}

	/**
	 * 部屋の床の色を変更します
	 * @param color 色
	 */
	@autobind
	public updateCarpetColor(color: string) {
		this.roomInfo.carpetColor = color;
		this.applyCarpetColor();
	}

	/**
	 * 部屋の種類を変更します
	 * @param type 種類
	 */
	@autobind
	public changeRoomType(type: string) {
		this.roomInfo.roomType = type;
		this.scene.remove(this.roomObj);
		this.loadRoom();
	}

	/**
	 * 部屋データを取得します
	 */
	@autobind
	public getRoomInfo() {
		for (const obj of this.objects) {
			const furniture = this.furnitures.find(f => f.id === obj.name);
			furniture.position.x = obj.position.x;
			furniture.position.y = obj.position.y;
			furniture.position.z = obj.position.z;
			furniture.rotation.x = obj.rotation.x;
			furniture.rotation.y = obj.rotation.y;
			furniture.rotation.z = obj.rotation.z;
		}

		return this.roomInfo;
	}

	/**
	 * 選択されている家具を取得します
	 */
	@autobind
	public getSelectedObject() {
		return this.selectedObject;
	}

	@autobind
	public findFurnitureById(id: string) {
		return this.furnitures.find(furniture => furniture.id === id);
	}

	/**
	 * レンダリングを終了します
	 */
	@autobind
	public destroy() {
		// Stop render loop
		window.cancelAnimationFrame(this.renderFrameRequestId);

		this.controls.dispose();
		this.scene.dispose();
	}
}
