import { Frustum } from 'three';
import { assert } from "../../../../core/assert.js";
import { plane3_compute_ray_intersection } from "../../../../core/geom/3d/plane/plane3_compute_ray_intersection.js";
import { v3_distance_above_plane } from "../../../../core/geom/vec3/v3_distance_above_plane.js";
import Vector1 from "../../../../core/geom/Vector1.js";
import Vector3 from "../../../../core/geom/Vector3.js";
import ObservedBoolean from "../../../../core/model/ObservedBoolean.js";
import ObservedEnum from "../../../../core/model/ObservedEnum.js";
import { frustum_from_camera } from "./frustum_from_camera.js";
import { ProjectionType } from "./ProjectionType.js";
import { quaternion_invert_orientation } from "./quaternion_invert_orientation.js";
import { unprojectPoint } from "./unprojectPoint.js";

/**
 * @class
 */
export class Camera {
    constructor() {
        /**
         * Whether clipping planes should be automatically calculated or not
         * @type {boolean}
         */
        this.autoClip = false;

        /**
         * Tunable parameter to prevent clipping planes from thrashing. Clipping planes must move by a certain portion of the current span before frustum gets shrunken
         * @type {number}
         */
        this.autoClipHysteresis = 0.33;

        this.object = null;

        /**
         *
         * @type {Vector1}
         */
        this.fov = new Vector1(45);

        /**
         *
         * @type {ObservedEnum<ProjectionType>}
         */
        this.projectionType = new ObservedEnum(ProjectionType.Perspective, ProjectionType);

        /**
         *
         * @type {ObservedBoolean}
         */
        this.active = new ObservedBoolean(true);

        /**
         * Near clipping plane
         * @type {number}
         * @private
         */
        this.__clip_far = 100;

        /**
         * Far clipping plane
         * @type {number}
         * @private
         */
        this.__clip_near = 0.1;
    }

    /**
     *
     * @param {Quaternion} output
     * @param {Quaternion} input
     */
    getReciprocalRotation(output, input) {
        quaternion_invert_orientation(output, input);
    }

    get clip_far() {
        return this.__clip_far;
    }

    set clip_far(v) {
        this.__clip_far = v;

        if (this.object !== null) {
            this.object.far = v;
        }
    }

    get clip_near() {
        return this.__clip_near;
    }

    set clip_near(v) {
        this.__clip_near = v;

        if (this.object !== null) {
            this.object.near = v;
        }
    }

    updateMatrices() {
        const c = this.object;

        if (c === null) {
            return;
        }

        c.updateProjectionMatrix();
        c.updateMatrixWorld(true);

        //update world inverse matrix
        c.matrixWorldInverse.copy(c.matrixWorld);
        c.matrixWorldInverse.invert();
    }

    projectRay(x, y, source, target) {
        Camera.projectRay(this.object, x, y, source, target);
    }

    /**
     *
     * @param {Camera} other
     */
    copy(other) {
        this.active.copy(other.active);
        this.projectionType.copy(other.projectionType);
        this.autoClip = other.autoClip;
    }

    /**
     *
     * @returns {Camera}
     */
    clone() {
        const clone = new Camera();

        clone.copy(this);

        return clone;
    }

    toJSON() {
        return {
            autoClip: this.autoClip,
            active: this.active.toJSON()
        };
    }

    fromJSON(json) {
        this.autoClip = json.autoClip;

        if (typeof json.active === "boolean") {
            this.active.fromJSON(json.active);
        } else {
            this.active.set(false);
        }
    }

    /**
     *
     * @param {Camera|THREE.PerspectiveCamera|THREE.OrthographicCamera} camera
     * @param {number} x
     * @param {number} y
     * @param {Vector3} out_source
     * @param {Vector3} out_direction
     */
    static projectRay(
        camera,
        x, y,
        out_source, out_direction
    ) {
        assert.defined(camera, "camera");
        assert.defined(camera.position, "Camera.position");


        assert.isNumber(x, 'x');
        assert.isNumber(y, "y");

        // assert.ok(x >= -1, `X(=${x}) must be greater than or equal to -1.0, not a clip-space coordinate`);
        // assert.ok(x <= 1, `X(=${x}) must be less than or equal to 1.0, not a clip-space coordinate`);

        // assert.ok(y >= -1, `Y(=${y}) must be greater than or equal to -1.0, not a clip-space coordinate`);
        // assert.ok(y <= 1, `Y(=${y}) must be less than or equal to 1.0, not a clip-space coordinate`);

        if (camera.isPerspectiveCamera || camera.isOrthographicCamera) {
            const m4_world = camera.matrixWorld.elements;
            const m4_projection_inverse = camera.projectionMatrixInverse.elements;

            scratch_v3_1.setFromMatrixPosition(m4_world);

            scratch_v3_0.set(x, y, 0.5);

            // NOTE: projection inverse and world matrices are used in "unproject" operation, we update those to make things work as intended. This is handled by the camera system

            unprojectPoint(
                scratch_v3_0,
                scratch_v3_0,
                m4_projection_inverse,
                m4_world
            );

            //get direction
            scratch_v3_0.sub(scratch_v3_1);
            scratch_v3_0.normalize();

            out_source.copy(scratch_v3_1);
            out_direction.copy(scratch_v3_0);

        } else {
            throw new Error('Unsupported camera type');
        }
    }

    /**
     *
     * @param {Matrix4} result
     */
    computeProjectionMatrix(result) {
        const camera = this.object;

        if (camera === null) {
            return false;
        }

        result.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);

        return true;
    }

    /**
     *
     * @param {Vector3} out
     * @param {number} origin_x
     * @param {number} origin_y
     * @param {number} origin_z
     * @param {number} direction_x
     * @param {number} direction_y
     * @param {number} direction_z
     * @param {number} plane_index
     */
    rayPlaneIntersection(
        out,
        origin_x, origin_y, origin_z,
        direction_x, direction_y, direction_z,
        plane_index
    ) {

        assert.isNonNegativeInteger(plane_index, 'plane_index');
        assert.lessThanOrEqual(plane_index, 5, `plane_index must be <= 5, was ${plane_index}`)

        frustum_from_camera(this.object, scratch_frustum);

        const plane = scratch_frustum.planes[plane_index];

        assert.defined(plane, 'plane');

        plane3_compute_ray_intersection(
            out,
            origin_x, origin_y, origin_z,
            direction_x, direction_y, direction_z,
            plane.normal.x, plane.normal.y, plane.normal.z,
            plane.constant
        );
    }

    /**
     *
     * @param {number} x
     * @param {number} y
     * @param {number} z
     * @param {Vector3} result
     */
    projectWorldPointOntoNearPlane(x, y, z, result) {

        frustum_from_camera(this.object, scratch_frustum);

        const near = scratch_frustum.planes[0];

        scratch_v3_0.set(x, y, z);

        const normal = near.normal;

        const distance_to_plane = v3_distance_above_plane(x, y, z, normal.x, normal.y, normal.z, near.constant);

        const negative_distance_to_plane = -distance_to_plane;

        result.set(
            normal.x * negative_distance_to_plane + x,
            normal.y * negative_distance_to_plane + y,
            normal.z * negative_distance_to_plane + z,
        );
    }
}

Camera.typeName = "Camera";

Camera.ProjectionType = ProjectionType;

const scratch_v3_0 = new Vector3();
const scratch_v3_1 = new Vector3();
const scratch_frustum = new Frustum();


