/*
 * Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *         http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import { Room } from "matrix-js-sdk/src/models/room";
import {
    ClientWidgetApi,
    IStickerActionRequest,
    IStickyActionRequest,
    ITemplateParams,
    IWidget,
    IWidgetApiRequest,
    IWidgetApiRequestEmptyData,
    IWidgetData,
    MatrixCapabilities,
    runTemplate,
    Widget,
    WidgetApiFromWidgetAction,
    IModalWidgetOpenRequest,
    IWidgetApiErrorResponseData,
    WidgetKind,
} from "matrix-widget-api";
import { StopGapWidgetDriver } from "./StopGapWidgetDriver";
import { EventEmitter } from "events";
import { WidgetMessagingStore } from "./WidgetMessagingStore";
import RoomViewStore from "../RoomViewStore";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import { OwnProfileStore } from "../OwnProfileStore";
import WidgetUtils from '../../utils/WidgetUtils';
import { IntegrationManagers } from "../../integrations/IntegrationManagers";
import SettingsStore from "../../settings/SettingsStore";
import { WidgetType } from "../../widgets/WidgetType";
import ActiveWidgetStore from "../ActiveWidgetStore";
import { objectShallowClone } from "../../utils/objects";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ElementWidgetActions, IViewRoomApiRequest } from "./ElementWidgetActions";
import { ModalWidgetStore } from "../ModalWidgetStore";
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
import { getCustomTheme } from "../../theme";
import CountlyAnalytics from "../../CountlyAnalytics";
import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { ELEMENT_CLIENT_ID } from "../../identifiers";
import { getUserLanguage } from "../../languageHandler";
import { WidgetVariableCustomisations } from "../../customisations/WidgetVariables";
import { arrayFastClone } from "../../utils/arrays";

// TODO: Destroy all of this code

interface IAppTileProps {
    // Note: these are only the props we care about

    app: IWidget;
    room: Room;
    userId: string;
    creatorUserId: string;
    waitForIframeLoad: boolean;
    whitelistCapabilities: string[];
    userWidget: boolean;
}

// TODO: Don't use this because it's wrong
export class ElementWidget extends Widget {
    constructor(private rawDefinition: IWidget) {
        super(rawDefinition);
    }

    public get templateUrl(): string {
        if (WidgetType.JITSI.matches(this.type)) {
            return WidgetUtils.getLocalJitsiWrapperUrl({
                forLocalRender: true,
                auth: super.rawData?.auth as string, // this.rawData can call templateUrl, do this to prevent looping
            });
        }
        return super.templateUrl;
    }

    public get popoutTemplateUrl(): string {
        if (WidgetType.JITSI.matches(this.type)) {
            return WidgetUtils.getLocalJitsiWrapperUrl({
                forLocalRender: false, // The only important difference between this and templateUrl()
                auth: super.rawData?.auth as string,
            });
        }
        return this.templateUrl; // use this instead of super to ensure we get appropriate templating
    }

    public get rawData(): IWidgetData {
        let conferenceId = super.rawData['conferenceId'];
        if (conferenceId === undefined) {
            // we'll need to parse the conference ID out of the URL for v1 Jitsi widgets
            const parsedUrl = new URL(super.templateUrl); // use super to get the raw widget URL
            conferenceId = parsedUrl.searchParams.get("confId");
        }
        let domain = super.rawData['domain'];
        if (domain === undefined) {
            // v1 widgets default to jitsi.riot.im regardless of user settings
            domain = "jitsi.riot.im";
        }

        let theme = new ThemeWatcher().getEffectiveTheme();
        if (theme.startsWith("custom-")) {
            const customTheme = getCustomTheme(theme.substr(7));
            // Jitsi only understands light/dark
            theme = customTheme.is_dark ? "dark" : "light";
        }

        // only allow light/dark through, defaulting to dark as that was previously the only state
        // accounts for legacy-light/legacy-dark themes too
        if (theme.includes("light")) {
            theme = "light";
        } else {
            theme = "dark";
        }

        return {
            ...super.rawData,
            theme,
            conferenceId,
            domain,
        };
    }

    public getCompleteUrl(params: ITemplateParams, asPopout=false): string {
        return runTemplate(asPopout ? this.popoutTemplateUrl : this.templateUrl, {
            ...this.rawDefinition,
            data: this.rawData,
        }, params);
    }
}

export class StopGapWidget extends EventEmitter {
    private messaging: ClientWidgetApi;
    private mockWidget: ElementWidget;
    private scalarToken: string;
    private roomId?: string;
    private kind: WidgetKind;
    private readUpToMap: {[roomId: string]: string} = {}; // room ID to event ID

    constructor(private appTileProps: IAppTileProps) {
        super();
        let app = appTileProps.app;

        // Backwards compatibility: not all old widgets have a creatorUserId
        if (!app.creatorUserId) {
            app = objectShallowClone(app); // clone to prevent accidental mutation
            app.creatorUserId = MatrixClientPeg.get().getUserId();
        }

        this.mockWidget = new ElementWidget(app);
        this.roomId = appTileProps.room?.roomId;
        this.kind = appTileProps.userWidget ? WidgetKind.Account : WidgetKind.Room; // probably
    }

    private get eventListenerRoomId(): string {
        // When widgets are listening to events, we need to make sure they're only
        // receiving events for the right room. In particular, room widgets get locked
        // to the room they were added in while account widgets listen to the currently
        // active room.

        if (this.roomId) return this.roomId;

        return RoomViewStore.getRoomId();
    }

    public get widgetApi(): ClientWidgetApi {
        return this.messaging;
    }

    /**
     * The URL to use in the iframe
     */
    public get embedUrl(): string {
        return this.runUrlTemplate({ asPopout: false });
    }

    /**
     * The URL to use in the popout
     */
    public get popoutUrl(): string {
        return this.runUrlTemplate({ asPopout: true });
    }

    private runUrlTemplate(opts = { asPopout: false }): string {
        const fromCustomisation = WidgetVariableCustomisations?.provideVariables?.() ?? {};
        const defaults: ITemplateParams = {
            widgetRoomId: this.roomId,
            currentUserId: MatrixClientPeg.get().getUserId(),
            userDisplayName: OwnProfileStore.instance.displayName,
            userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(),
            clientId: ELEMENT_CLIENT_ID,
            clientTheme: SettingsStore.getValue("theme"),
            clientLanguage: getUserLanguage(),
        };
        const templated = this.mockWidget.getCompleteUrl(Object.assign(defaults, fromCustomisation), opts?.asPopout);

        const parsed = new URL(templated);

        // Add in some legacy support sprinkles (for non-popout widgets)
        // TODO: Replace these with proper widget params
        // See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833
        if (!opts?.asPopout) {
            parsed.searchParams.set('widgetId', this.mockWidget.id);
            parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]);

            // Give the widget a scalar token if we're supposed to (more legacy)
            // TODO: Stop doing this
            if (this.scalarToken) {
                parsed.searchParams.set('scalar_token', this.scalarToken);
            }
        }

        // Replace the encoded dollar signs back to dollar signs. They have no special meaning
        // in HTTP, but URL parsers encode them anyways.
        return parsed.toString().replace(/%24/g, '$');
    }

    public get isManagedByManager(): boolean {
        return !!this.scalarToken;
    }

    public get started(): boolean {
        return !!this.messaging;
    }

    private get widgetId() {
        return this.messaging.widget.id;
    }

    private onOpenModal = async (ev: CustomEvent<IModalWidgetOpenRequest>) => {
        ev.preventDefault();
        if (ModalWidgetStore.instance.canOpenModalWidget()) {
            ModalWidgetStore.instance.openModalWidget(ev.detail.data, this.mockWidget);
            this.messaging.transport.reply(ev.detail, {}); // ack
        } else {
            this.messaging.transport.reply(ev.detail, {
                error: {
                    message: "Unable to open modal at this time",
                },
            });
        }
    };

    public start(iframe: HTMLIFrameElement) {
        if (this.started) return;
        const allowedCapabilities = this.appTileProps.whitelistCapabilities || [];
        const driver = new StopGapWidgetDriver(allowedCapabilities, this.mockWidget, this.kind, this.roomId);
        this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
        this.messaging.on("preparing", () => this.emit("preparing"));
        this.messaging.on("ready", () => this.emit("ready"));
        this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal);
        WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.messaging);

        if (!this.appTileProps.userWidget && this.appTileProps.room) {
            ActiveWidgetStore.setRoomId(this.mockWidget.id, this.appTileProps.room.roomId);
        }

        // Always attach a handler for ViewRoom, but permission check it internally
        this.messaging.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent<IViewRoomApiRequest>) => {
            ev.preventDefault(); // stop the widget API from auto-rejecting this

            // Check up front if this is even a valid request
            const targetRoomId = (ev.detail.data || {}).room_id;
            if (!targetRoomId) {
                return this.messaging.transport.reply(ev.detail, <IWidgetApiErrorResponseData>{
                    error: { message: "Room ID not supplied." },
                });
            }

            // Check the widget's permission
            if (!this.messaging.hasCapability(ElementWidgetCapabilities.CanChangeViewedRoom)) {
                return this.messaging.transport.reply(ev.detail, <IWidgetApiErrorResponseData>{
                    error: { message: "This widget does not have permission for this action (denied)." },
                });
            }

            // at this point we can change rooms, so do that
            defaultDispatcher.dispatch({
                action: 'view_room',
                room_id: targetRoomId,
            });

            // acknowledge so the widget doesn't freak out
            this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});
        });

        // Populate the map of "read up to" events for this widget with the current event in every room.
        // This is a bit inefficient, but should be okay. We do this for all rooms in case the widget
        // requests timeline capabilities in other rooms down the road. It's just easier to manage here.
        for (const room of MatrixClientPeg.get().getRooms()) {
            // Timelines are most recent last
            const events = room.getLiveTimeline()?.getEvents() || [];
            const roomEvent = events[events.length - 1];
            if (!roomEvent) continue; // force later code to think the room is fresh
            this.readUpToMap[room.roomId] = roomEvent.getId();
        }

        // Attach listeners for feeding events - the underlying widget classes handle permissions for us
        MatrixClientPeg.get().on('event', this.onEvent);
        MatrixClientPeg.get().on('Event.decrypted', this.onEventDecrypted);

        this.messaging.on(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`,
            (ev: CustomEvent<IStickyActionRequest>) => {
                if (this.messaging.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
                    if (WidgetType.JITSI.matches(this.mockWidget.type)) {
                        CountlyAnalytics.instance.trackJoinCall(this.appTileProps.room.roomId, true, true);
                    }
                    ActiveWidgetStore.setWidgetPersistence(this.mockWidget.id, ev.detail.data.value);
                    ev.preventDefault();
                    this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); // ack
                }
            },
        );

        // TODO: Replace this event listener with appropriate driver functionality once the API
        // establishes a sane way to send events back and forth.
        this.messaging.on(`action:${WidgetApiFromWidgetAction.SendSticker}`,
            (ev: CustomEvent<IStickerActionRequest>) => {
                if (this.messaging.hasCapability(MatrixCapabilities.StickerSending)) {
                    // Acknowledge first
                    ev.preventDefault();
                    this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});

                    // Send the sticker
                    defaultDispatcher.dispatch({
                        action: 'm.sticker',
                        data: ev.detail.data,
                        widgetId: this.mockWidget.id,
                    });
                }
            },
        );

        if (WidgetType.STICKERPICKER.matches(this.mockWidget.type)) {
            this.messaging.on(`action:${ElementWidgetActions.OpenIntegrationManager}`,
                (ev: CustomEvent<IWidgetApiRequest>) => {
                    // Acknowledge first
                    ev.preventDefault();
                    this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});

                    // First close the stickerpicker
                    defaultDispatcher.dispatch({ action: "stickerpicker_close" });

                    // Now open the integration manager
                    // TODO: Spec this interaction.
                    const data = ev.detail.data;
                    const integType = data?.integType;
                    const integId = <string>data?.integId;

                    // TODO: Open the right integration manager for the widget
                    if (SettingsStore.getValue("feature_many_integration_managers")) {
                        IntegrationManagers.sharedInstance().openAll(
                            MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()),
                            `type_${integType}`,
                            integId,
                        );
                    } else {
                        IntegrationManagers.sharedInstance().getPrimaryManager().open(
                            MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()),
                            `type_${integType}`,
                            integId,
                        );
                    }
                },
            );
        }
    }

    public async prepare(): Promise<void> {
        // Ensure the variables are ready for us to be rendered before continuing
        await (WidgetVariableCustomisations?.isReady?.() ?? Promise.resolve());

        if (this.scalarToken) return;
        const existingMessaging = WidgetMessagingStore.instance.getMessaging(this.mockWidget);
        if (existingMessaging) this.messaging = existingMessaging;
        try {
            if (WidgetUtils.isScalarUrl(this.mockWidget.templateUrl)) {
                const managers = IntegrationManagers.sharedInstance();
                if (managers.hasManager()) {
                    // TODO: Pick the right manager for the widget
                    const defaultManager = managers.getPrimaryManager();
                    if (WidgetUtils.isScalarUrl(defaultManager.apiUrl)) {
                        const scalar = defaultManager.getScalarClient();
                        this.scalarToken = await scalar.getScalarToken();
                    }
                }
            }
        } catch (e) {
            // All errors are non-fatal
            console.error("Error preparing widget communications: ", e);
        }
    }

    public stop(opts = { forceDestroy: false }) {
        if (!opts?.forceDestroy && ActiveWidgetStore.getPersistentWidgetId() === this.mockWidget.id) {
            console.log("Skipping destroy - persistent widget");
            return;
        }
        if (!this.started) return;
        WidgetMessagingStore.instance.stopMessaging(this.mockWidget);
        ActiveWidgetStore.delRoomId(this.mockWidget.id);

        if (MatrixClientPeg.get()) {
            MatrixClientPeg.get().off('event', this.onEvent);
            MatrixClientPeg.get().off('Event.decrypted', this.onEventDecrypted);
        }
    }

    private onEvent = (ev: MatrixEvent) => {
        MatrixClientPeg.get().decryptEventIfNeeded(ev);
        if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
        this.feedEvent(ev);
    };

    private onEventDecrypted = (ev: MatrixEvent) => {
        if (ev.isDecryptionFailure()) return;
        this.feedEvent(ev);
    };

    private feedEvent(ev: MatrixEvent) {
        if (!this.messaging) return;

        // Check to see if this event would be before or after our "read up to" marker. If it's
        // before, or we can't decide, then we assume the widget will have already seen the event.
        // If the event is after, or we don't have a marker for the room, then we'll send it through.
        //
        // This approach of "read up to" prevents widgets receiving decryption spam from startup or
        // receiving out-of-order events from backfill and such.
        const upToEventId = this.readUpToMap[ev.getRoomId()];
        if (upToEventId) {
            // Small optimization for exact match (prevent search)
            if (upToEventId === ev.getId()) {
                return;
            }

            let isBeforeMark = true;

            // Timelines are most recent last, so reverse the order and limit ourselves to 100 events
            // to avoid overusing the CPU.
            const timeline = MatrixClientPeg.get().getRoom(ev.getRoomId()).getLiveTimeline();
            const events = arrayFastClone(timeline.getEvents()).reverse().slice(0, 100);

            for (const timelineEvent of events) {
                if (timelineEvent.getId() === upToEventId) {
                    break;
                } else if (timelineEvent.getId() === ev.getId()) {
                    isBeforeMark = false;
                    break;
                }
            }

            if (isBeforeMark) {
                // Ignore the event: it is before our interest.
                return;
            }
        }

        this.readUpToMap[ev.getRoomId()] = ev.getId();

        const raw = ev.getEffectiveEvent();
        this.messaging.feedEvent(raw, this.eventListenerRoomId).catch(e => {
            console.error("Error sending event to widget: ", e);
        });
    }
}
