import html2canvas from 'html2canvas';
import { BimCoreApi } from '../BimCoreApi';
import {
    Axis,
    Color3,
    Mesh,
    MeshBuilder,
    Nullable,
    Observable,
    Observer,
    Plane,
    StandardMaterial,
    Texture,
    TransformNode,
    Vector3,
    Matrix,
    Color4
} from '../loader/babylonjs-import';
import { Html2CanvasPoint, HtmlPointParent } from './Html2CanvasPoint';
import { CoordinateTracker, TrackCoordinate2D } from './CoordinateTracker';

export interface Html2CanvasPointFactoryHandler<Point extends Html2CanvasPoint> {
    (api: BimCoreApi, plane: Plane): Point;
}

/**
 * Renders HTML elements to a plane in 3D space.
 * WARNING: This class does NOT sanitize its contents. Make sure to sanitize your HTML in the htmlConversion parameter or before passing it
 * to this class.
 */
export class Html2CanvasPlane<Point extends Html2CanvasPoint = Html2CanvasPoint>
    implements HtmlPointParent<Html2CanvasPoint>
{
    private _maxTextureSize = 512 * 256;
    /**
     * @hidden
     */
    _pointTracker: CoordinateTracker<Html2CanvasPoint>;
    public readonly onPointTrackableScreen: Observable<TrackCoordinate2D<Point>>;
    private _html: string;
    private _originalHtml: string;
    private _mesh: Mesh;
    private _minScale = 0.01;
    private readonly _texture: Texture;
    private readonly _cornerCoordinatesLocal = new Map<Html2CanvasPoint, Vector3>();
    private readonly _cornerPoints: Html2CanvasPoint[] = [];
    private readonly _centerPoint: Html2CanvasPoint;
    public readonly onHtmlChangeObservable = new Observable<string>();
    public readonly onBackgroundColorChangeObservable = new Observable<Color4>();
    private _onParentWorldMatrixUpdateObserver: Nullable<Observer<TransformNode>>;
    private readonly _containerDiv = document.createElement('div');
    private static readonly _parser = new DOMParser();
    private readonly _tmpVecA = Vector3.Zero();
    private readonly _tmpVecB = Vector3.Zero();
    private readonly _tmpVecC = Vector3.Zero();
    private readonly _tmpMatrix = Matrix.Identity();

    /**
     * Creates a new Html2CanvasPlane and adds it to the scene.
     * @param api BimApi instance.
     * @param id unique id
     * @param html The original HTML
     * @param htmlConversion The method for modifying the HTML string. NOTE: This class does not sanitize HTML so this method is a good place to do so.
     * @param position The position of the plane.
     * @param normal The normal of the plane.
     * @param scale The scale of the plane. If a number is passed in the scale will automatically adjust for aspect ratio and parent scale. If a Vector3 is passed in it is the absolute scaling of the plane.
     * @param rotationAroundNormal How much the plane should be rotated around the normal in radians.
     * @param backgroundColor Background color for the resulting canvas which is then applied to the plane.
     * @param pointFactory Factory method for the points the user can drag to resize the plane etc.
     * @param parent Babylonjs parent node.
     * @returns The new a promise with the Html2CanvasPlane.
     */
    static async create<Point extends Html2CanvasPoint = Html2CanvasPoint>(
        api: BimCoreApi,
        id: string,
        html: string,
        htmlConversion: (html: string) => string,
        position: Vector3,
        normal: Vector3,
        scale: Vector3 | number,
        rotationAroundNormal: number,
        backgroundColor: Color4,
        pointFactory: Html2CanvasPointFactoryHandler<Point>,
        parent: Nullable<TransformNode>
    ): Promise<Html2CanvasPlane<Point>> {
        new Texture(null, api.viewer.scene, false, true, Texture.TRILINEAR_SAMPLINGMODE);
        const h2cPlane = new Html2CanvasPlane<Point>(
            api,
            id,
            html,
            htmlConversion,
            position,
            normal,
            scale,
            rotationAroundNormal,
            backgroundColor,
            pointFactory,
            parent
        );

        await h2cPlane.renderToPlane();
        return h2cPlane;
    }

    private constructor(
        private readonly _api: BimCoreApi,
        readonly id: string,
        html: string,
        private readonly _htmlConversion: (html: string) => string,
        private readonly _position: Vector3,
        private readonly _normal: Vector3,
        private readonly _scale: Vector3 | number,
        public rotationAroundNormal: number,
        private _backgroundColor: Color4,
        private readonly _pointFactory: Html2CanvasPointFactoryHandler<Point>,
        private _parent: Nullable<TransformNode>
    ) {
        //to avoid z fighting.
        this._position.addInPlace(this._normal.scale(0.02));
        this._texture.hasAlpha = true;
        this._originalHtml = html;
        this._html = this._htmlConversion(html);
        this._pointTracker = new CoordinateTracker<Html2CanvasPoint>(this._api, true);
        this.onPointTrackableScreen = this._pointTracker.onUpdateObservable as unknown as Observable<
            TrackCoordinate2D<Point>
        >;

        for (let i = 0; i < 4; i++) {
            this._cornerPoints.push(
                this._pointFactory(this._api, Plane.FromPositionAndNormal(this._position, this._normal))
            );
            this._cornerPoints[i]._parent = this;
            this._pointTracker.track(this._cornerPoints[i], this._cornerPoints[i]);
        }

        this._cornerCoordinatesLocal.set(this._cornerPoints[0], new Vector3(-0.5, 0, -0.5));
        this._cornerCoordinatesLocal.set(this._cornerPoints[1], new Vector3(0.5, 0, -0.5));
        this._cornerCoordinatesLocal.set(this._cornerPoints[2], new Vector3(0.5, 0, 0.5));
        this._cornerCoordinatesLocal.set(this._cornerPoints[3], new Vector3(-0.5, 0, 0.5));

        this._centerPoint = this._pointFactory(this._api, Plane.FromPositionAndNormal(this._position, this._normal));
        this._centerPoint._parent = this;
        this._pointTracker.track(this._centerPoint, this._centerPoint);
        this.registerParentWorldMatrixCallback();

        //default style
        this._containerDiv.style.position = 'absolute';
        // this._containerDiv.style.width = 'fit-content';
        this._containerDiv.style.overflowWrap = 'anywhere';
        this._containerDiv.style.paddingRight = '0.5em';
        this._containerDiv.style.paddingLeft = '0.5em';
        // this._containerDiv.style.maxWidth = '600px';
        this._containerDiv.style.opacity = '0.0';
        this._containerDiv.id = `containerDiv_${this.id}`;
    }

    private registerParentWorldMatrixCallback(): void {
        if (this._parent) {
            let updateFlag = this._parent.getWorldMatrix().updateFlag;
            this._onParentWorldMatrixUpdateObserver = this._parent.onAfterWorldMatrixUpdateObservable.add(() => {
                if (this._parent && updateFlag !== this._parent.getWorldMatrix().updateFlag && this._mesh) {
                    for (const p of this._cornerPoints) {
                        p.recalculateWorldCoords = true;
                        updateFlag = this._parent.getWorldMatrix().updateFlag;
                    }

                    this._mesh.computeWorldMatrix();
                    this.updatePointsFromLocalSpaceCoordinates();
                }
            });
        }
    }

    /**
     * Sets the parent of the Html2CanvasPlane without keeping the position in world space.
     * @param node new parent for the Html2CanvasPlane.
     */
    public set parent(node: Nullable<TransformNode>) {
        if (this._onParentWorldMatrixUpdateObserver && this.parent) {
            this.parent.onAfterWorldMatrixUpdateObservable.remove(this._onParentWorldMatrixUpdateObserver);
        }
        this._parent = node;
        this._mesh.parent = node;
        this.registerParentWorldMatrixCallback();
        this._mesh.computeWorldMatrix();
        for (const p of this._cornerPoints) {
            p.recalculateWorldCoords = true;
        }
        this._centerPoint.recalculateWorldCoords = true;
    }

    /**
     * Gets the parent TransformNode.
     */
    public get parent(): Nullable<TransformNode> {
        return this._parent;
    }

    /**
     * Gets the position of the plane in world space.
     */
    public get position(): Vector3 {
        return this._mesh.position;
    }

    /**
     * Gets the scaling of the plane.
     */
    public get scaling(): Vector3 {
        return this._mesh.scaling;
    }

    /**
     * @returns The worldmatrix of the plane mesh.
     */
    worldMatrix(): Matrix {
        if (!this._mesh) {
            throw new Error('Can not get world matrix, no mesh exists.');
        }
        return this._mesh.computeWorldMatrix();
    }

    private parseHtml(html: string): Document {
        const htmlDoc = Html2CanvasPlane._parser.parseFromString(html, 'text/html');
        return htmlDoc;
    }

    /**
     * Renders the HTML to a plane in 3D space.
     * @returns The mesh on which the HTML is rendered.
     */
    private async renderToPlane(): Promise<Mesh> {
        await this.updateTexture();

        this._mesh = MeshBuilder.CreateGround(
            `Html2CanvasPlane_${this.id}`,
            { subdivisions: 1 },
            this._api.viewer.scene
        );
        this._mesh.parent = this._parent;
        this._mesh.id = this.id;
        const materialGround = new StandardMaterial(`Html2CanvasPlaneMaterial_${this.id}`, this._api.viewer.scene);
        materialGround.specularColor = Color3.Black();
        materialGround.emissiveColor = Color3.Black();
        materialGround.diffuseTexture = this._texture;
        materialGround.useAlphaFromDiffuseTexture = true;
        this._mesh.material = materialGround;
        this._api.viewer.selectables.attach(this._mesh);
        this._mesh.position.copyFrom(this._position);

        Vector3.CrossToRef(this._normal, this._mesh.up, this._tmpVecA);
        const ang = Vector3.GetAngleBetweenVectors(this._normal, this._mesh.up, this._tmpVecA);

        this._mesh.rotate(this._tmpVecA, ang);
        this._mesh.rotate(this._normal, Math.PI + this.rotationAroundNormal);

        if (this._scale instanceof Vector3) {
            this._mesh.scaling.x = this._scale.x;
            this._mesh.scaling.z = this._scale.z;
        } else {
            this._mesh.scaling.x = this._scale * (this._texture.getSize().width / this._texture.getSize().height);
            this._mesh.scaling.z = this._scale;
            if (this._parent) {
                this._parent.getWorldMatrix().invertToRef(this._tmpMatrix);
                Vector3.TransformCoordinatesToRef(this._mesh.scaling, this._tmpMatrix, this._mesh.scaling);
            }
        }

        let cornerCoordLocal = Vector3.Zero();
        for (const p of this._cornerPoints) {
            cornerCoordLocal = this._cornerCoordinatesLocal.get(p)!;
            p.setLocals(cornerCoordLocal.x, cornerCoordLocal.y, cornerCoordLocal.z);
            this._pointTracker.track(p, p);
        }

        this._centerPoint.setLocals(0, 0, 0);
        this._pointTracker.track(this._centerPoint, this._centerPoint);
        this._mesh.material?.freeze();
        return this._mesh;
    }

    /**
     * Updates the container div element with the current HTML.
     */
    private updateContainerDiv(): HTMLDivElement {
        //clear innerhtml so we dont end up with duplicate html.
        this._containerDiv.innerHTML = '';

        const htmlDoc = this.parseHtml(this._html);

        htmlDoc.body.childNodes.forEach((node) => {
            this._containerDiv.appendChild(node);
        });

        return this._containerDiv;
    }

    /**
     * Renders the containerdiv to the plane.
     * @returns A promise that will resolve when the texture is rendered.
     */
    public async renderContainerDivToPlane(): Promise<Texture> {
        document.body.appendChild(this._containerDiv);
        const size = this._containerDiv.clientHeight * this._containerDiv.clientWidth;

        const maxScale = Math.sqrt(this._maxTextureSize / size);

        const canvas = await html2canvas(this._containerDiv, {
            onclone: (doc) => {
                doc.getElementById(this._containerDiv.id)!.style.opacity = '1.0';
            },
            scale: maxScale,
            backgroundColor: this._backgroundColor.toHexString()
        });

        document.body.removeChild(this._containerDiv);

        const dataURL = canvas.toDataURL();

        return new Promise<Texture>((resolve) => {
            this._texture.updateURL(dataURL, undefined, () => {
                URL.revokeObjectURL(dataURL);
                resolve(this._texture);
            });
        });
    }

    /**
     * Updates the container div and renders the container div to the plane.
     * @returns A promise which resolves when the texture is updated.
     */
    public async updateTexture(): Promise<Texture> {
        this.updateContainerDiv();
        return this.renderContainerDivToPlane();
    }

    /**
     * @hidden
     */
    rotate(point: Html2CanvasPoint): void {
        const cornerCoordLocal = this._cornerCoordinatesLocal.get(point)!;
        this._tmpVecA.set(cornerCoordLocal.x, cornerCoordLocal.y, cornerCoordLocal.z); //previous point position in local space
        this._tmpVecB.set(point.localX, point.localY, point.localZ); //current point position in local space
        let angle = Vector3.GetAngleBetweenVectorsOnPlane(this._tmpVecA, this._tmpVecB, Axis.Y);
        //This is needed for rotations to work when the plane does not have a 1:1 aspect ratio.
        angle =
            angle /
            (this._mesh.scaling.z > this._mesh.scaling.x
                ? this._mesh.scaling.z / this._mesh.scaling.x
                : this._mesh.scaling.x / this._mesh.scaling.z);
        this._mesh.rotate(Axis.Y, angle);
        this.rotationAroundNormal += angle % (Math.PI * 2);
        this.updatePointsFromLocalSpaceCoordinates();
    }

    /**
     * @hidden
     */
    scale(point: Html2CanvasPoint, retainAspectRatio = true): void {
        const cornerCoordLocal = this._cornerCoordinatesLocal.get(point);
        if (!cornerCoordLocal) {
            throw new Error(`Local corner coordinate could not be found for point ${point}`);
        }

        this._tmpVecA.set(cornerCoordLocal.x, cornerCoordLocal.y, cornerCoordLocal.z); //previous point position in local space
        this._tmpVecB.set(point.localX, point.localY, point.localZ); //current point position in local space

        const prevX = this._mesh.scaling.x;
        const prevZ = this._mesh.scaling.z;
        const angle = Vector3.GetAngleBetweenVectorsOnPlane(
            this._tmpVecA,
            this._tmpVecB,
            Vector3.Cross(this._tmpVecA, this._tmpVecB)
        );

        if (angle < Math.PI / 2) {
            if (retainAspectRatio) {
                let ratio = this._tmpVecB.length() / this._tmpVecA.length();
                //this is done since we need to halve the amount that we scale the mesh by.
                //but if we just divide by 2, 1.2 would become 0.6, but we want it to become 1.1
                //since we only want to divide the scaling factor by 2.
                ratio = (ratio - 1) / 2 + 1;
                const min = Math.min(this._mesh.scaling.x, this._mesh.scaling.z);
                const maxRatio = min / this._minScale;
                this._mesh.scaling.x *= Math.max(ratio, 1 / maxRatio);
                this._mesh.scaling.z *= Math.max(ratio, 1 / maxRatio);
            } else {
                let ratioX = this._tmpVecB.x / this._tmpVecA.x;
                let ratioZ = this._tmpVecB.z / this._tmpVecA.z;
                ratioX = (ratioX - 1) / 2 + 1;
                ratioZ = (ratioZ - 1) / 2 + 1;

                const maxRatioX = this._mesh.scaling.x / this._minScale;
                this._mesh.scaling.x *= Math.max(ratioX, 1 / maxRatioX);

                const maxRatioZ = this._mesh.scaling.z / this._minScale;
                this._mesh.scaling.z *= Math.max(ratioZ, 1 / maxRatioZ);
            }

            const deltaX = this._mesh.scaling.x - prevX;
            const deltaZ = this._mesh.scaling.z - prevZ;
            this._tmpVecC.set(deltaX * cornerCoordLocal.x, 0, deltaZ * cornerCoordLocal.z);
            Vector3.TransformCoordinatesToRef(
                this._tmpVecC,
                this._mesh.getWorldMatrix().getRotationMatrix(),
                this._tmpVecC
            );

            this._mesh.position.addInPlace(this._tmpVecC);
        }

        this.updatePointsFromLocalSpaceCoordinates();
    }

    private updatePointsFromLocalSpaceCoordinates(): void {
        for (const p of this._cornerPoints) {
            const localSpace = this._cornerCoordinatesLocal.get(p)!;
            p.setLocals(localSpace.x, localSpace.y, localSpace.z);
            this._pointTracker.track(p, p);
        }

        //again, what happens if the user changes the pivot point of the mesh?
        this._centerPoint.setLocals(0, 0, 0);
        this._pointTracker.track(this._centerPoint, this._centerPoint);
        this._api.viewer.wakeRenderLoop();
    }

    /**
     * @hidden
     */
    translate(): void {
        if (this._parent) {
            this._parent.getWorldMatrix().invertToRef(this._tmpMatrix);
            Vector3.TransformCoordinatesFromFloatsToRef(
                this._centerPoint.x,
                this._centerPoint.y,
                this._centerPoint.z,
                this._tmpMatrix,
                this._tmpVecA
            );
            this._mesh.position.copyFrom(this._tmpVecA);
        } else {
            this._mesh.position.set(this._centerPoint.x, this._centerPoint.y, this._centerPoint.z);
        }
        this.updatePointsFromLocalSpaceCoordinates();
    }

    /**
     * Sets the HTML and applies it to the texture
     * @param html The new HTML to be rendered.
     * @param htmlConversion The conversion function for the HTML, note that it is recommended to santize the input first.
     */
    public async setHtmlAndUpdate(html: string, htmlConversion: (html: string) => string): Promise<void> {
        this._originalHtml = html;
        this._html = htmlConversion(html);
        await this.updateTexture();

        const textureSize = this._texture.getSize();

        if (this._scale instanceof Vector3) {
            this._mesh.scaling.x = this._scale.z * (textureSize.width / textureSize.height);
        } else {
            this._mesh.scaling.z = this._scale;
            this._mesh.scaling.x = this._scale * (textureSize.width / textureSize.height);
            if (this._parent) {
                this._parent.getWorldMatrix().invertToRef(this._tmpMatrix);
                Vector3.TransformCoordinatesToRef(this._mesh.scaling, this._tmpMatrix, this._mesh.scaling);
            }
        }
        this.updatePointsFromLocalSpaceCoordinates();
        this.onHtmlChangeObservable.notifyObservers(this._originalHtml);
    }

    public async setHtml(html: string, htmlConversion: (html: string) => string): Promise<void> {
        this._originalHtml = html;
        this._html = htmlConversion(html);
        this.updateContainerDiv();
    }

    /**
     * Sets the background color and updates the texture which is rendered on the plane mesh.
     * @param col New background color.
     */
    async setBackgroundColorAndUpdateTexture(col: Color4): Promise<void> {
        this._backgroundColor = col;
        await this.updateTexture();
        this.onBackgroundColorChangeObservable.notifyObservers(this._backgroundColor);
    }

    /**
     * Returns the original HTML before it was put through the htmlConversion parameter method.
     */
    public get originalHtml(): string {
        return this._originalHtml;
    }

    public get containerDiv(): HTMLDivElement {
        return this._containerDiv;
    }

    /**
     * @returns the babylonjs mesh
     */
    public get mesh(): Mesh {
        return this._mesh;
    }

    private clear(): void {
        this._pointTracker.clear();
        this._mesh.setEnabled(false);
    }

    /**
     * Get the current minScale of the plane.
     */
    public get minScale(): number {
        return this._minScale;
    }

    /**
     * Set the minimum scale of the plane. This value will be applied on all scaling axes.
     * @param scale New minimum scale to apply to the scaling axes.
     */
    public set minScale(scale: number) {
        this._minScale = scale;
    }

    /**
     * Disposes the Html2CanvasPlane.
     */
    public dispose(): void {
        this.clear();
        this._mesh.dispose();
    }

    /**
     * Sets the max size of the texture used to render the html.
     * NOTE: Increasing the max texture size will have a negative performance impact
     * @param size new max texture size (width * height)
     */
    public set maxTextureSize(size: number) {
        this._maxTextureSize = size;
    }

    /**
     * Gets the max texture size (width * height).
     */
    public get maxTextureSize(): number {
        return this._maxTextureSize;
    }

    public centerPoint(): Html2CanvasPoint {
        return this._centerPoint;
    }

    /**
     * Gets the style of the HTML element that will be rendered to the plane.
     * NOTE: Do not change background color here as the canvas renderer has its own background color property,
     * use {@link setBackgroundColorAndUpdateTexture} instead.
     */
    public get style(): CSSStyleDeclaration {
        return this._containerDiv.style;
    }
}
