import {
	AnimationClip,
	Bone,
	FileLoader,
	Loader,
	Quaternion,
	QuaternionKeyframeTrack,
	Skeleton,
	Vector3,
	VectorKeyframeTrack
} from 'three';

/**
 * A loader for the BVH format.
 *
 * Imports BVH files and outputs a single {@link Skeleton} and {@link AnimationClip}.
 * The loader only supports BVH files containing a single root right now.
 *
 * ```js
 * const loader = new BVHLoader();
 * const result = await loader.loadAsync( 'models/bvh/pirouette.bvh' );
 *
 * // visualize skeleton
 * const skeletonHelper = new THREE.SkeletonHelper( result.skeleton.bones[ 0 ] );
 * scene.add( result.skeleton.bones[ 0 ] );
 * scene.add( skeletonHelper );
 *
 * // play animation clip
 * mixer = new THREE.AnimationMixer( result.skeleton.bones[ 0 ] );
 * mixer.clipAction( result.clip ).play();
 * ```
 *
 * @augments Loader
 * @three_import import { BVHLoader } from 'three/addons/loaders/BVHLoader.js';
 */
class BVHLoader extends Loader {

	/**
	 * Constructs a new BVH loader.
	 *
	 * @param {LoadingManager} [manager] - The loading manager.
	 */
	constructor( manager ) {

		super( manager );

		/**
		 * Whether to animate bone positions or not.
		 *
		 * @type {boolean}
		 * @default true
		 */
		this.animateBonePositions = true;

		/**
		 * Whether to animate bone rotations or not.
		 *
		 * @type {boolean}
		 * @default true
		 */
		this.animateBoneRotations = true;

	}

	/**
	 * Starts loading from the given URL and passes the loaded BVH asset
	 * to the `onLoad()` callback.
	 *
	 * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
	 * @param {function({skeleton:Skeleton,clip:AnimationClip})} onLoad - Executed when the loading process has been finished.
	 * @param {onProgressCallback} onProgress - Executed while the loading is in progress.
	 * @param {onErrorCallback} onError - Executed when errors occur.
	 */
	load( url, onLoad, onProgress, onError ) {

		const scope = this;

		const loader = new FileLoader( scope.manager );
		loader.setPath( scope.path );
		loader.setRequestHeader( scope.requestHeader );
		loader.setWithCredentials( scope.withCredentials );
		loader.load( url, function ( text ) {

			try {

				onLoad( scope.parse( text ) );

			} catch ( e ) {

				if ( onError ) {

					onError( e );

				} else {

					console.error( e );

				}

				scope.manager.itemError( url );

			}

		}, onProgress, onError );

	}

	/**
	 * Parses the given BVH data and returns the resulting data.
	 *
	 * @param {string} text - The raw BVH data as a string.
	 * @return {{skeleton:Skeleton,clip:AnimationClip}} An object representing the parsed asset.
	 */
	parse( text ) {

		// reads a string array (lines) from a BVH file
		// and outputs a skeleton structure including motion data

		// returns thee root node:
		// { name: '', channels: [], children: [] }
		function readBvh( lines ) {

			// read model structure

			if ( nextLine( lines ) !== 'HIERARCHY' ) {

				console.error( 'THREE.BVHLoader: HIERARCHY expected.' );

			}

			const list = []; // collects flat array of all bones
			const root = readNode( lines, nextLine( lines ), list );

			// read motion data

			if ( nextLine( lines ) !== 'MOTION' ) {

				console.error( 'THREE.BVHLoader: MOTION expected.' );

			}

			// number of frames

			let tokens = nextLine( lines ).split( /[\s]+/ );
			const numFrames = parseInt( tokens[ 1 ] );

			if ( isNaN( numFrames ) ) {

				console.error( 'THREE.BVHLoader: Failed to read number of frames.' );

			}

			// frame time

			tokens = nextLine( lines ).split( /[\s]+/ );
			const frameTime = parseFloat( tokens[ 2 ] );

			if ( isNaN( frameTime ) ) {

				console.error( 'THREE.BVHLoader: Failed to read frame time.' );

			}

			// read frame data line by line

			for ( let i = 0; i < numFrames; i ++ ) {

				tokens = nextLine( lines ).split( /[\s]+/ );
				readFrameData( tokens, i * frameTime, root );

			}

			return list;

		}

		/*
			Recursively reads data from a single frame into the bone hierarchy.
			The passed bone hierarchy has to be structured in the same order as the BVH file.
			keyframe data is stored in bone.frames.

			- data: splitted string array (frame values), values are shift()ed so
			this should be empty after parsing the whole hierarchy.
			- frameTime: playback time for this keyframe.
			- bone: the bone to read frame data from.
		*/
		function readFrameData( data, frameTime, bone ) {

			// end sites have no motion data

			if ( bone.type === 'ENDSITE' ) return;

			// add keyframe

			const keyframe = {
				time: frameTime,
				position: new Vector3(),
				rotation: new Quaternion()
			};

			bone.frames.push( keyframe );

			const quat = new Quaternion();

			const vx = new Vector3( 1, 0, 0 );
			const vy = new Vector3( 0, 1, 0 );
			const vz = new Vector3( 0, 0, 1 );

			// parse values for each channel in node

			for ( let i = 0; i < bone.channels.length; i ++ ) {

				switch ( bone.channels[ i ] ) {

					case 'Xposition':
						keyframe.position.x = parseFloat( data.shift().trim() );
						break;
					case 'Yposition':
						keyframe.position.y = parseFloat( data.shift().trim() );
						break;
					case 'Zposition':
						keyframe.position.z = parseFloat( data.shift().trim() );
						break;
					case 'Xrotation':
						quat.setFromAxisAngle( vx, parseFloat( data.shift().trim() ) * Math.PI / 180 );
						keyframe.rotation.multiply( quat );
						break;
					case 'Yrotation':
						quat.setFromAxisAngle( vy, parseFloat( data.shift().trim() ) * Math.PI / 180 );
						keyframe.rotation.multiply( quat );
						break;
					case 'Zrotation':
						quat.setFromAxisAngle( vz, parseFloat( data.shift().trim() ) * Math.PI / 180 );
						keyframe.rotation.multiply( quat );
						break;
					default:
						console.warn( 'THREE.BVHLoader: Invalid channel type.' );

				}

			}

			// parse child nodes

			for ( let i = 0; i < bone.children.length; i ++ ) {

				readFrameData( data, frameTime, bone.children[ i ] );

			}

		}

		/*
		 Recursively parses the HIERARCHY section of the BVH file

		 - lines: all lines of the file. lines are consumed as we go along.
		 - firstline: line containing the node type and name e.g. 'JOINT hip'
		 - list: collects a flat list of nodes

		 returns: a BVH node including children
		*/
		function readNode( lines, firstline, list ) {

			const node = { name: '', type: '', frames: [] };
			list.push( node );

			// parse node type and name

			let tokens = firstline.split( /[\s]+/ );

			if ( tokens[ 0 ].toUpperCase() === 'END' && tokens[ 1 ].toUpperCase() === 'SITE' ) {

				node.type = 'ENDSITE';
				node.name = 'ENDSITE'; // bvh end sites have no name

			} else {

				node.name = tokens[ 1 ];
				node.type = tokens[ 0 ].toUpperCase();

			}

			if ( nextLine( lines ) !== '{' ) {

				console.error( 'THREE.BVHLoader: Expected opening { after type & name' );

			}

			// parse OFFSET

			tokens = nextLine( lines ).split( /[\s]+/ );

			if ( tokens[ 0 ] !== 'OFFSET' ) {

				console.error( 'THREE.BVHLoader: Expected OFFSET but got: ' + tokens[ 0 ] );

			}

			if ( tokens.length !== 4 ) {

				console.error( 'THREE.BVHLoader: Invalid number of values for OFFSET.' );

			}

			const offset = new Vector3(
				parseFloat( tokens[ 1 ] ),
				parseFloat( tokens[ 2 ] ),
				parseFloat( tokens[ 3 ] )
			);

			if ( isNaN( offset.x ) || isNaN( offset.y ) || isNaN( offset.z ) ) {

				console.error( 'THREE.BVHLoader: Invalid values of OFFSET.' );

			}

			node.offset = offset;

			// parse CHANNELS definitions

			if ( node.type !== 'ENDSITE' ) {

				tokens = nextLine( lines ).split( /[\s]+/ );

				if ( tokens[ 0 ] !== 'CHANNELS' ) {

					console.error( 'THREE.BVHLoader: Expected CHANNELS definition.' );

				}

				const numChannels = parseInt( tokens[ 1 ] );
				node.channels = tokens.splice( 2, numChannels );
				node.children = [];

			}

			// read children

			while ( true ) {

				const line = nextLine( lines );

				if ( line === '}' ) {

					return node;

				} else {

					node.children.push( readNode( lines, line, list ) );

				}

			}

		}

		/*
			recursively converts the internal bvh node structure to a Bone hierarchy

			source: the bvh root node
			list: pass an empty array, collects a flat list of all converted THREE.Bones

			returns the root Bone
		*/
		function toTHREEBone( source, list ) {

			const bone = new Bone();
			list.push( bone );

			bone.position.add( source.offset );
			bone.name = source.name;

			if ( source.type !== 'ENDSITE' ) {

				for ( let i = 0; i < source.children.length; i ++ ) {

					bone.add( toTHREEBone( source.children[ i ], list ) );

				}

			}

			return bone;

		}

		/*
			builds an AnimationClip from the keyframe data saved in each bone.

			bone: bvh root node

			returns: an AnimationClip containing position and quaternion tracks
		*/
		function toTHREEAnimation( bones ) {

			const tracks = [];

			// create a position and quaternion animation track for each node

			for ( let i = 0; i < bones.length; i ++ ) {

				const bone = bones[ i ];

				if ( bone.type === 'ENDSITE' )
					continue;

				// track data

				const times = [];
				const positions = [];
				const rotations = [];

				for ( let j = 0; j < bone.frames.length; j ++ ) {

					const frame = bone.frames[ j ];

					times.push( frame.time );

					// the animation system animates the position property,
					// so we have to add the joint offset to all values

					positions.push( frame.position.x + bone.offset.x );
					positions.push( frame.position.y + bone.offset.y );
					positions.push( frame.position.z + bone.offset.z );

					rotations.push( frame.rotation.x );
					rotations.push( frame.rotation.y );
					rotations.push( frame.rotation.z );
					rotations.push( frame.rotation.w );

				}

				if ( scope.animateBonePositions ) {

					tracks.push( new VectorKeyframeTrack( bone.name + '.position', times, positions ) );

				}

				if ( scope.animateBoneRotations ) {

					tracks.push( new QuaternionKeyframeTrack( bone.name + '.quaternion', times, rotations ) );

				}

			}

			return new AnimationClip( 'animation', - 1, tracks );

		}

		/*
			returns the next non-empty line in lines
		*/
		function nextLine( lines ) {

			let line;
			// skip empty lines
			while ( ( line = lines.shift().trim() ).length === 0 ) { }

			return line;

		}

		const scope = this;

		const lines = text.split( /[\r\n]+/g );

		const bones = readBvh( lines );

		const threeBones = [];
		toTHREEBone( bones[ 0 ], threeBones );

		const threeClip = toTHREEAnimation( bones );

		return {
			skeleton: new Skeleton( threeBones ),
			clip: threeClip
		};

	}

}

export { BVHLoader };
