import get from 'lodash/get';
import find from 'lodash/find';
import flow from 'lodash/flow';

import { EventTypes as VisualizationEventTypes } from 'redhotmagma-visualization/Constants';
import {
    createAdapterConfig,
    createVisualizationAdapter,
} from 'redhotmagma-visualization/visualizationAdapterCreator';
import { THREE, TWEEN } from 'redhotmagma-visualization/ExposedModules';

import AbstractVisualizationService from 'Framework/Services/AbstractVisualizationService';
import { t } from 'Framework/i18n';
import { Services } from '../ServiceLocator';
import { getConf, isProduction } from '../configuration';
import { getSelectedDesignArea, getSelectedViewName } from '../Reducers/DesignArea/Selectors';
import {
    getConfigurator,
    getItem,
    getSelectedVariantIdentifier,
    getCurrentVisualizationSettings,
    visualizationModeCheck,
    getConfiguration,
    isDesignerProductPreviewMode,
} from '../Reducers/Configurator/Selectors';
import sendMessage from '../../Utils/Function/sendMessage';
import Observable from '../../Utils/Decorators/Observable';
import { NoVisualization } from '../Screens/Configurator/Components/NoVisualization/NoVisualization';
import { getCurrentDesignView } from '../Reducers/DesignView/Selectors';
import { getSelectedConfigurationMode, isDesignerMode } from '../Reducers/ConfigurationMode/Selectors';

const EventTypes = {
    ComponentChange: 'VisualizationService/ComponentChange',
    VisualizationUpdate: 'VisualizationService/VisualizationUpdate',
};

// if the visualization script is configured, load it asap
const visualizationScriptSrc = getConf('visualization.useVisualizationScript.src', false);
const visualizationScriptPromise = new Promise((resolve, reject) => {
    if (!visualizationScriptSrc) {
        resolve();
    }
    const script = document.createElement('script');
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
    script.src = visualizationScriptSrc;
});

export default class VisualizationService extends AbstractVisualizationService {
    observable = new Observable(this);

    host = getConf('network.host');
    adapterConfig = (() => {
        const cfg = createAdapterConfig(
            { host: this.host },
            {
                default: {
                    Component: NoVisualization,
                },
                '3dVariant': {
                    basePath: `${this.host}/content/3D`,
                },
            }
        );
        // mapping for backward compatibility
        return {
            ...cfg,
            '3d': cfg['3dVariant'],
            '2d': cfg['2dVariant'],
            layer: cfg['2dLayer'],
        };
    })();

    /**
     * Reference of the visualization instance
     * @type {null|object}
     * @private
     */
    _component = null;

    /**
     * Function to release the camera movement restrictions
     * @type {null|function}
     * @private
     */
    _removeRestrictedUserMovementZoomHandler = null;

    init() {
        // skip initialization if the widget mode is active
        if (window.location.pathname.replace(/^\/([^/]*).*$/, '$1') === 'widget') {
            return;
        }
        Services.store.observeStore(getCurrentVisualizationSettings, target => {
            target !== 'fallback' &&
                this.setComponent(createVisualizationAdapter(this.adapterConfig, target || 'default'));
        });

        Services.store.observeStore(
            flow(getConfigurator, getItem, ({ parentItemIdentifier }) => parentItemIdentifier),
            itemIdentifier => {
                sendMessage(this.getComponent(), 'updateItemPath', itemIdentifier);
            }
        );

        Services.store.observeStore(
            flow(getConfigurator, getSelectedVariantIdentifier),
            variantIdentifier => {
                sendMessage(this.getComponent(), 'updateMaterialVariantPath', variantIdentifier);
            }
        );

        Services.store.observeStore(getSelectedDesignArea, this.designAreaChangeHandler.bind(this));

        Services.store.observeStore(
            state => state.designView.selectedDesignView,
            () => {
                this.designViewChangeHandler(getCurrentDesignView(Services.store.state));
            }
        );

        Services.store.observeStore(
            getSelectedConfigurationMode,
            this.configurationModeChangeHandler.bind(this)
        );
    }

    configurationModeChangeHandler(mode) {
        Services.designData.clearDesignAreaSelection();
        this.initDesignAreaInteraction(mode);
    }

    async updateDesignAreaList(designAreaList) {
        await sendMessage(this.getComponent(), 'updateDesignAreaList', designAreaList);
        await this.designViewChangeHandler(getCurrentDesignView(Services.store.state));
    }

    isReadyToRender() {
        return sendMessage(this.getComponent(), 'isInitialized');
    }

    isReadyToRenderCustomDesign() {
        return sendMessage(this.getComponent(), 'isCustomizableMaterialAvailable');
    }

    render() {
        return sendMessage(this.getComponent(), 'render');
    }

    update2DMaxZoom(maxZoom) {
        return sendMessage(this.getComponent(), 'updateMaxZoomSetting', maxZoom);
    }

    updateVariant(variantIdentifier) {
        return sendMessage(this.getComponent(), 'updateMaterialVariantPath', variantIdentifier);
    }

    setActiveDesignArea(designArea) {
        return sendMessage(this.getComponent(), 'setActiveDesignArea', designArea);
    }

    getActiveDesignArea() {
        return sendMessage(this.getComponent(), 'getActiveDesignArea');
    }

    async designAreaChangeHandler() {
        const designArea = getSelectedDesignArea(Services.store.state);
        const visualization = this.getComponent();
        this.setActiveDesignArea(designArea);
        const state = Services.store.state;
        const modeCheck = visualizationModeCheck(state);
        if (modeCheck.is3D()) {
            const viewName = getSelectedViewName(designArea);
            await sendMessage(visualization, 'jumpToView', viewName);
        } else if (modeCheck.is2D() && designArea.identifier) {
            await sendMessage(visualization, 'jumpToArea', designArea.identifier);
        }

        // make sure every state update and change event is triggered before updating the visualization
        // root case: get the canvas data with cleared selection
        setTimeout(() => Services.designData.updateVisualization());
    }

    getViewChangedPromise() {
        return this._viewChangedPromise;
    }

    async designViewChangeHandler(designView) {
        // applies only for 2D mode (in 3D there is only one design view)
        if (!designView) {
            return;
        }
        const visualization = this.getComponent();
        this._viewChangedPromise = sendMessage(visualization, 'setView', designView);
        await this._viewChangedPromise;
    }

    setComponent(component) {
        if (!component) {
            throw new Error('Component must be provided!');
        }

        sendMessage(this._component, 'dispose');

        this._component = component;

        visualizationScriptPromise
            ?.then(() => {
                window.initVisualizationScript?.(component, { THREE, TWEEN });
            })
            .catch(e => {
                // eslint-disable-next-line no-console
                console.error('Error while loading visualization script', e);
            });

        if (isDesignerProductPreviewMode()) {
            sendMessage(component, 'init');
            sendMessage(component, 'setSize', { width: 10, height: 10 });
        }

        // add listener to the three adapter to catch intersections with the pointer
        component.observable.on(VisualizationEventTypes.Intersection, evt => {
            if (Services.designData.handleCloseIfNeeded()) {
                // if it performed any actions, just return
                return;
            }

            // get the texture data from the intersection
            const {
                data: { getTextureHit },
            } = evt;
            // so we can test if a design area was clicked
            const area = this.testTextureIntersection(getTextureHit());
            if (area) {
                // the design area change already invokes the jumpToView function
                Services.designData.selectDesignArea(area.identifier);
            } else {
                // clear the selection if the click did not hit the area
                Services.designData.clearDesignAreaSelection();
            }
        });

        // add click-away listener too, so the design area selection can be cleared
        component.observable.on(VisualizationEventTypes.ClickAway, () => {
            Services.designData.handleCloseIfNeeded();
        });

        this.addAreaHoverHandler(component);

        // the design areas should be redrawn when the material is updated
        component.observable.on(VisualizationEventTypes.MaterialUpdated, async updateEventObject => {
            await Services.designData.updateVisualization(updateEventObject);
            this.observable.dispatch(EventTypes.VisualizationUpdate, updateEventObject);
        });

        component.observable.on(VisualizationEventTypes.LoadStart, () => {
            Services.ui.show('loadVisualization');
        });

        component.observable.on(VisualizationEventTypes.LoadFinished, () => {
            Services.ui.hide('loadVisualization');
        });

        component.observable.on(VisualizationEventTypes.LoadError, () => {
            Services.ui.hide('loadVisualization');
        });

        component.observable.on(VisualizationEventTypes.Error, () => {
            Services.ui.reportError(t('visualization.errors.visualizationNotPossible'));
        });

        component.observable.on(VisualizationEventTypes.EmptySceneError, () => {
            Services.ui.reportError(t('visualization.errors.visualizationNotPossible'));
        });

        // in dev mode expose the three object to the window
        if (!isProduction()) {
            window.visualization = component;
        }

        if (component.getScreenshotsSync) {
            this.registerScreenshotsGetter(component.getScreenshotsSync.bind(component));
        }
        if (component.setView) {
            component.setView(Services.store.state.designView.selectedDesignView);
        }
        if (component.update) {
            this.registerUpdateFunction(component.update.bind(component));
            this.update(getConfiguration(Services.configurator.state));
        }
        if (component.getDrawingContext) {
            this.registerTextureUpdateFunction(this.drawDesignAreasOnTexture.bind(this));
        }
        if (component.toggleCameraLock) {
            this.registerCameraLockFunction(component.toggleCameraLock.bind(component));
        }
        if (component.toggleHitTest) {
            this.registerToggleHitTestFunction(component.toggleHitTest.bind(component));
        }

        const isAdminMode = getConf('adminMode');
        sendMessage(component, 'clearViewIfEmpty', !!isAdminMode);

        const state = Services.store.state;
        const visualizationOverlayMode = visualizationModeCheck(state).is2DOverlay();
        sendMessage(component, 'setOverlayMode', !!visualizationOverlayMode);

        const visualizationOptions = getConf('visualization.options');
        sendMessage(component, 'setOptions', visualizationOptions);

        const visualizationOptions3D = getConf('visualization.options3D');
        sendMessage(component, 'setOptions3D', visualizationOptions3D);

        this.observable.dispatch(EventTypes.ComponentChange, { component });

        return this;
    }

    addAreaHoverHandler(component) {
        let isAreaHovered = false;
        const toggleAreaHovered = isHovered => {
            if (isAreaHovered !== isHovered && component?.getContainer) {
                isAreaHovered = isHovered;
                component.getContainer().style.cursor = isAreaHovered ? 'pointer' : '';
            }
        };
        component.observable.on(VisualizationEventTypes.HoverIn, evt => {
            // get the texture data from the intersection
            const {
                data: { getTextureHit },
            } = evt;
            // so we can test if a design area was clicked
            const area = this.testTextureIntersection(getTextureHit());
            toggleAreaHovered(!!area);
        });

        component.observable.on(VisualizationEventTypes.HoverOut, () => {
            toggleAreaHovered(false);
        });
    }

    disposeComponent() {
        sendMessage(this._component, 'dispose');
        this._component = null;
        this.observable.dispatch(EventTypes.ComponentChange, { component: null });
    }

    getComponent() {
        return this._component;
    }

    registerComponentChangeListener(fn) {
        return this.observable.on(EventTypes.ComponentChange, fn);
    }

    registerVisualizationUpdateListener(fn) {
        this.observable.on(EventTypes.VisualizationUpdate, fn);
    }

    update(configuration) {
        if (this._onUpdateFn) {
            this._onUpdateFn(configuration);
        } else {
            // save it for later if there is no update function registered yet
            this._configuration = configuration;
        }
        return this;
    }

    registerUpdateFunction(onUpdateFn) {
        this._onUpdateFn = onUpdateFn;
        // call the update method if there was already a call for it
        if (this._configuration) {
            this.update(this._configuration);
            delete this._configuration;
        }
        return this;
    }

    registerCameraLockFunction(cameraLockFunction) {
        this._cameraLockFunction = cameraLockFunction;
        return this;
    }

    registerToggleHitTestFunction(toggleHitTest) {
        this._toggleHitTest = toggleHitTest;
        return this;
    }

    /**
     * Register the getScreenshots function in the service so it can be proxied to others via this instance
     * @param {function} getScreenshotsFn
     */
    registerScreenshotsGetter(getScreenshotsFn) {
        this._getScreenshotsFn = getScreenshotsFn;
        return this;
    }

    /**
     * Calls the registered getScreenshot function or returns a resolved Promise
     * @param {object} options The options object forwarded the getScreenshot function
     * @returns {*}
     */
    async getScreenshots(options = {}) {
        let screenshots = null;
        if (this._getScreenshotsFn) {
            await this._getScreenshotsFn(result => {
                screenshots = result;
            }, options);
        }
        return screenshots;
    }

    abortScreenshotting() {
        return sendMessage(this.getComponent(), 'abortScreenshotting');
    }

    /**
     * Register a callback for the updateTexture method
     * If there was already a texture update call it triggers the callback immediately with the latest argument.
     * @param {function} onUpdateFn
     * @returns {VisualizationService}
     */
    registerTextureUpdateFunction(onUpdateFn) {
        this._onTextureUpdateFn = onUpdateFn;
        // call the update method if there was already a call for it
        if (this._textureUpdateRequest) {
            this.updateTexture(this._textureUpdateRequest);
            delete this._textureUpdateRequest;
        }
        return this;
    }

    /**
     * Calls the previously registered texture update function, if there is any
     * Otherwise it stores the argument internally, so it can be used in the registerTextureUpdateFunction method.
     * @param {*} textureUpdateInfo
     */
    async updateTexture(textureUpdateInfo = {}) {
        if (this._onTextureUpdateFn) {
            await this._onTextureUpdateFn(textureUpdateInfo);
        } else {
            // save it for later if there is no update function registered yet
            this._textureUpdateRequest = textureUpdateInfo;
        }
    }

    async drawDesignAreasOnTexture({ drawingFunction, updateEventObject }) {
        try {
            const getDrawingContext = this._component.getDrawingContext.bind(this._component);
            drawingFunction && (await drawingFunction(getDrawingContext, updateEventObject));
        } catch (e) {
            // eslint-disable-next-line no-console
            console.log('Skipping texture update: drawing context is not available.');
        }
    }

    disableUserMovement() {
        if (this._cameraLockFunction) {
            this._cameraLockFunction(true);
        }
    }

    enableUserMovement() {
        if (this._cameraLockFunction) {
            this._cameraLockFunction(false);
        }
    }

    restrictUserMovement() {
        const component = this.getComponent();
        if (!component) {
            return;
        }
        const zoomHandler = ({ data: { direction } }) => {
            if (direction === 'out') {
                this._removeRestrictedUserMovementZoomHandler &&
                    this._removeRestrictedUserMovementZoomHandler();
                Services.designData.clearDesignAreaSelection();
            }
        };
        component.observable.on(VisualizationEventTypes.Zoomed, zoomHandler);
        this._removeRestrictedUserMovementZoomHandler = () => {
            component.observable.off(VisualizationEventTypes.Zoomed, zoomHandler);
            this._removeRestrictedUserMovementZoomHandler = null;
        };
        sendMessage(component, 'restrictUserMovement');
    }

    freeUserMovement() {
        this._removeRestrictedUserMovementZoomHandler && this._removeRestrictedUserMovementZoomHandler();
        sendMessage(this.getComponent(), 'freeUserMovement');
    }

    toggleHitTest(enabled) {
        if (this._toggleHitTest) {
            this._toggleHitTest(enabled);
        }
    }

    initDesignAreaInteraction(configurationMode = getSelectedConfigurationMode(Services.store.state)) {
        this.enableDesignAreaInteraction(isDesignerMode(configurationMode));
    }

    enableDesignAreaInteraction(enable) {
        Services.designData.toggleHighlight(enable);
        Services.visualization.toggleHitTest(enable);
    }

    /**
     * Tests if the point from the given texture intersection point is inside of a design area
     * @param textureIntersectionData
     * @see Three.getIntersection
     * @see Three.getTextureHit
     * @returns {*} The design area that contains the given point if any, false otherwise
     */
    testTextureIntersection(textureIntersectionData) {
        if (textureIntersectionData) {
            const { x, y, designAreaIdentifier, currentView } = textureIntersectionData;
            let designAreas = Services.designData.getDesignAreas();

            // If the designAreaIdentifier is provided just try to find it in the list.
            // This can be used in any visualization adapter that maps different materials (with own texture) to
            // each design areas. Currently it is being used in the Three2D adapter.
            if (designAreaIdentifier !== undefined) {
                const designAreaSource = get(currentView, 'designAreas') || designAreas;
                const foundArea = find(designAreaSource, { identifier: designAreaIdentifier }) || false;
                if (foundArea && foundArea.position && Object.keys(foundArea.position).length > 0) {
                    designAreas = [foundArea];
                } else {
                    return foundArea;
                }
            }
            // Otherwise it can be treated as a single texture that contains all the areas combined into one.
            // In this case the visualization adapter cannot give a hint about the clicked area,
            // therefore we have to find it by checking the intersection point falls into any of the area ranges.
            for (let area of designAreas) {
                const { width: aWidth, height: aHeight } = area.position;
                const { x: tX, y: tY } = reversePointTransform(area.position, x, y);
                const left = -aWidth / 2;
                const right = left + +aWidth;
                const top = -aHeight / 2;
                const bottom = top + +aHeight;
                if (tX >= left && tX <= right && tY >= top && tY <= bottom) {
                    return area;
                }
            }
        }
        return false;
    }
}

/**
 * This function was copied from the redhotmagma-configurator-administrationgui project
 * original source: src/Screens/Designer/SubScreens/Visualization/Components/FunDraw.js
 * It transforms the given (x, y) coordinates to a locale coordinate of the given object.
 * FIXME: replace if the function is available as an export of a standalone module
 * @param object
 * @param x
 * @param y
 * @returns {{x: number, y: number}}
 */
export const reversePointTransform = (object, x, y) => {
    const { x: cx, y: cy, angle } = {
        x: 0,
        y: 0,
        angle: 0,
        ...object,
    };
    const ox = x - cx;
    const oy = y - cy;
    const cosAi = Math.cos(-angle);
    const sinAi = Math.sin(-angle);
    return {
        x: ox * cosAi - oy * sinAi,
        y: oy * cosAi + ox * sinAi,
    };
};
