/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Copyright 2017, 2018 New Vector Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2015, 2016 OpenMarket Ltd

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/

import React from "react";
import {
    MatrixEvent,
    Room,
    RoomEvent,
    RoomMember,
    RoomMemberEvent,
    RoomState,
    RoomStateEvent,
    User,
    UserEvent,
    EventType,
    ClientEvent,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { throttle } from "lodash";
import { Button, Tooltip } from "@vector-im/compound-web";
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add-solid";

import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import { isValid3pidInvite } from "../../../RoomInvite";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import BaseCard from "../right_panel/BaseCard";
import TruncatedList from "../elements/TruncatedList";
import Spinner from "../elements/Spinner";
import SearchBox from "../../structures/SearchBox";
import { ButtonEvent } from "../elements/AccessibleButton";
import EntityTile from "./EntityTile";
import MemberTile from "./MemberTile";
import BaseAvatar from "../avatars/BaseAvatar";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import PosthogTrackers from "../../../PosthogTrackers";
import { SDKContext } from "../../../contexts/SDKContext";
import { canInviteTo } from "../../../utils/room/canInviteTo";
import { inviteToRoom } from "../../../utils/room/inviteToRoom";
import { Action } from "../../../dispatcher/actions";

const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
const SHOW_MORE_INCREMENT = 100;

interface IProps {
    roomId: string;
    searchQuery: string;
    onClose(): void;
    onSearchQueryChanged: (query: string) => void;
}

interface IState {
    loading: boolean;
    filteredJoinedMembers: Array<RoomMember>;
    filteredInvitedMembers: Array<RoomMember | MatrixEvent>;
    canInvite: boolean;
    truncateAtJoined: number;
    truncateAtInvited: number;
}

export default class MemberList extends React.Component<IProps, IState> {
    private readonly showPresence: boolean;
    private mounted = false;

    public static contextType = SDKContext;
    public declare context: React.ContextType<typeof SDKContext>;
    private tiles: Map<string, MemberTile> = new Map();

    public constructor(props: IProps, context: React.ContextType<typeof SDKContext>) {
        super(props, context);
        this.state = this.getMembersState([], []);
        this.showPresence = context?.memberListStore.isPresenceEnabled() ?? true;
        this.mounted = true;
        this.listenForMembersChanges();
    }

    private listenForMembersChanges(): void {
        const cli = MatrixClientPeg.safeGet();
        cli.on(RoomStateEvent.Update, this.onRoomStateUpdate);
        cli.on(RoomMemberEvent.Name, this.onRoomMemberName);
        cli.on(RoomStateEvent.Events, this.onRoomStateEvent);
        // We listen for changes to the lastPresenceTs which is essentially
        // listening for all presence events (we display most of not all of
        // the information contained in presence events).
        cli.on(UserEvent.LastPresenceTs, this.onUserPresenceChange);
        cli.on(UserEvent.Presence, this.onUserPresenceChange);
        cli.on(UserEvent.CurrentlyActive, this.onUserPresenceChange);
        cli.on(ClientEvent.Room, this.onRoom); // invites & joining after peek
        cli.on(RoomEvent.MyMembership, this.onMyMembership);
    }

    public componentDidMount(): void {
        this.updateListNow(true);
    }

    public componentWillUnmount(): void {
        this.mounted = false;
        const cli = MatrixClientPeg.get();
        if (cli) {
            cli.removeListener(RoomStateEvent.Update, this.onRoomStateUpdate);
            cli.removeListener(RoomMemberEvent.Name, this.onRoomMemberName);
            cli.removeListener(RoomEvent.MyMembership, this.onMyMembership);
            cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvent);
            cli.removeListener(ClientEvent.Room, this.onRoom);
            cli.removeListener(UserEvent.LastPresenceTs, this.onUserPresenceChange);
            cli.removeListener(UserEvent.Presence, this.onUserPresenceChange);
            cli.removeListener(UserEvent.CurrentlyActive, this.onUserPresenceChange);
        }

        // cancel any pending calls to the rate_limited_funcs
        this.updateList.cancel();
    }

    private get canInvite(): boolean {
        const cli = MatrixClientPeg.safeGet();
        const room = cli.getRoom(this.props.roomId);

        return !!room && canInviteTo(room);
    }

    private getMembersState(invitedMembers: Array<RoomMember>, joinedMembers: Array<RoomMember>): IState {
        return {
            loading: false,
            filteredJoinedMembers: joinedMembers,
            filteredInvitedMembers: invitedMembers,
            canInvite: this.canInvite,

            // ideally we'd size this to the page height, but
            // in practice I find that a little constraining
            truncateAtJoined: INITIAL_LOAD_NUM_MEMBERS,
            truncateAtInvited: INITIAL_LOAD_NUM_INVITED,
        };
    }

    private onUserPresenceChange = (event: MatrixEvent | undefined, user: User): void => {
        // Attach a SINGLE listener for global presence changes then locate the
        // member tile and re-render it. This is more efficient than every tile
        // ever attaching their own listener.
        const tile = this.tiles.get(user.userId);
        if (tile) {
            this.updateList(); // reorder the membership list
        }
    };

    private onRoom = (room: Room): void => {
        if (room.roomId !== this.props.roomId) {
            return;
        }
        // We listen for room events because when we accept an invite
        // we need to wait till the room is fully populated with state
        // before refreshing the member list else we get a stale list.
        this.updateListNow(true);
    };

    private onMyMembership = (room: Room, membership: string, oldMembership?: string): void => {
        if (
            room.roomId === this.props.roomId &&
            membership === KnownMembership.Join &&
            oldMembership !== KnownMembership.Join
        ) {
            // we just joined the room, load the member list
            this.updateListNow(true);
        }
    };

    private onRoomStateUpdate = (state: RoomState): void => {
        if (state.roomId !== this.props.roomId) return;
        this.updateList();
    };

    private onRoomMemberName = (ev: MatrixEvent, member: RoomMember): void => {
        if (member.roomId !== this.props.roomId) {
            return;
        }
        this.updateList();
    };

    private onRoomStateEvent = (event: MatrixEvent): void => {
        if (event.getRoomId() === this.props.roomId && event.getType() === EventType.RoomThirdPartyInvite) {
            this.updateList();
        }

        if (this.canInvite !== this.state.canInvite) this.setState({ canInvite: this.canInvite });
    };

    private updateList = throttle(
        () => {
            this.updateListNow(false);
        },
        500,
        { leading: true, trailing: true },
    );

    // XXX: exported for tests
    public async updateListNow(showLoadingSpinner?: boolean): Promise<void> {
        if (!this.mounted) {
            return;
        }
        if (showLoadingSpinner) {
            this.setState({ loading: true });
        }
        const { joined, invited } = await this.context.memberListStore.loadMemberList(
            this.props.roomId,
            this.props.searchQuery,
        );
        if (!this.mounted) {
            return;
        }
        this.setState({
            loading: false,
            filteredJoinedMembers: joined,
            filteredInvitedMembers: invited,
        });
    }

    private createOverflowTileJoined = (overflowCount: number, totalCount: number): JSX.Element => {
        return this.createOverflowTile(overflowCount, totalCount, this.showMoreJoinedMemberList);
    };

    private createOverflowTileInvited = (overflowCount: number, totalCount: number): JSX.Element => {
        return this.createOverflowTile(overflowCount, totalCount, this.showMoreInvitedMemberList);
    };

    private createOverflowTile = (overflowCount: number, totalCount: number, onClick: () => void): JSX.Element => {
        // For now we'll pretend this is any entity. It should probably be a separate tile.
        const text = _t("common|and_n_others", { count: overflowCount });
        return (
            <EntityTile
                className="mx_EntityTile_ellipsis"
                avatarJsx={
                    <BaseAvatar
                        url={require("@vector-im/compound-design-tokens/icons/overflow-horizontal.svg").default}
                        name="..."
                        size="36px"
                    />
                }
                name={text}
                showPresence={false}
                onClick={onClick}
            />
        );
    };

    private showMoreJoinedMemberList = (): void => {
        this.setState({
            truncateAtJoined: this.state.truncateAtJoined + SHOW_MORE_INCREMENT,
        });
    };

    private showMoreInvitedMemberList = (): void => {
        this.setState({
            truncateAtInvited: this.state.truncateAtInvited + SHOW_MORE_INCREMENT,
        });
    };

    public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>, snapshot?: any): void {
        if (prevProps.searchQuery !== this.props.searchQuery) {
            this.updateListNow(false);
        }
    }

    private onSearchQueryChanged = (searchQuery: string): void => {
        this.props.onSearchQueryChanged(searchQuery);
    };

    private onPending3pidInviteClick = (inviteEvent: MatrixEvent): void => {
        dis.dispatch({
            action: Action.View3pidInvite,
            event: inviteEvent,
        });
    };

    private getPending3PidInvites(): MatrixEvent[] {
        // include 3pid invites (m.room.third_party_invite) state events.
        // The HS may have already converted these into m.room.member invites so
        // we shouldn't add them if the 3pid invite state key (token) is in the
        // member invite (content.third_party_invite.signed.token)
        const room = MatrixClientPeg.safeGet().getRoom(this.props.roomId);

        if (room) {
            return room.currentState.getStateEvents("m.room.third_party_invite").filter(function (e) {
                if (!isValid3pidInvite(e)) return false;

                // discard all invites which have a m.room.member event since we've
                // already added them.
                const memberEvent = room.currentState.getInviteForThreePidToken(e.getStateKey()!);
                if (memberEvent) return false;
                return true;
            });
        }

        return [];
    }

    private makeMemberTiles(members: Array<RoomMember | MatrixEvent>): JSX.Element[] {
        return members.map((m) => {
            if (m instanceof RoomMember) {
                // Is a Matrix invite
                return (
                    <MemberTile
                        key={m.userId}
                        member={m}
                        ref={(tile) => {
                            if (tile) this.tiles.set(m.userId, tile);
                            else this.tiles.delete(m.userId);
                        }}
                        showPresence={this.showPresence}
                    />
                );
            } else {
                // Is a 3pid invite
                return (
                    <EntityTile
                        key={m.getStateKey()}
                        name={m.getContent().display_name}
                        showPresence={false}
                        onClick={() => this.onPending3pidInviteClick(m)}
                    />
                );
            }
        });
    }

    private getChildrenJoined = (start: number, end: number): Array<JSX.Element> => {
        return this.makeMemberTiles(this.state.filteredJoinedMembers.slice(start, end));
    };

    private getChildCountJoined = (): number => this.state.filteredJoinedMembers.length;

    private getChildrenInvited = (start: number, end: number): Array<JSX.Element> => {
        let targets = this.state.filteredInvitedMembers;
        if (end > this.state.filteredInvitedMembers.length) {
            targets = targets.concat(this.getPending3PidInvites());
        }

        return this.makeMemberTiles(targets.slice(start, end));
    };

    private getChildCountInvited = (): number => {
        return this.state.filteredInvitedMembers.length + (this.getPending3PidInvites() || []).length;
    };

    public render(): React.ReactNode {
        if (this.state.loading) {
            return (
                <BaseCard
                    id="memberlist-panel"
                    className="mx_MemberList"
                    ariaLabelledBy="memberlist-panel-tab"
                    role="tabpanel"
                    header={_t("common|people")}
                    onClose={this.props.onClose}
                >
                    <Spinner />
                </BaseCard>
            );
        }

        const cli = MatrixClientPeg.safeGet();
        const room = cli.getRoom(this.props.roomId);
        let inviteButton: JSX.Element | undefined;

        if (room?.getMyMembership() === KnownMembership.Join && shouldShowComponent(UIComponent.InviteUsers)) {
            const inviteButtonText = room.isSpaceRoom() ? _t("space|invite_this_space") : _t("room|invite_this_room");

            const button = (
                <Button
                    size="sm"
                    kind="secondary"
                    className="mx_MemberList_invite"
                    onClick={this.onInviteButtonClick}
                    disabled={!this.state.canInvite}
                >
                    <UserAddIcon width="1em" height="1em" />
                    {inviteButtonText}
                </Button>
            );

            if (this.state.canInvite) {
                inviteButton = button;
            } else {
                inviteButton = <Tooltip label={_t("member_list|invite_button_no_perms_tooltip")}>{button}</Tooltip>;
            }
        }

        let invitedHeader;
        let invitedSection;
        if (this.getChildCountInvited() > 0) {
            invitedHeader = <h2>{_t("member_list|invited_list_heading")}</h2>;
            invitedSection = (
                <TruncatedList
                    className="mx_MemberList_section mx_MemberList_invited"
                    truncateAt={this.state.truncateAtInvited}
                    createOverflowElement={this.createOverflowTileInvited}
                    getChildren={this.getChildrenInvited}
                    getChildCount={this.getChildCountInvited}
                />
            );
        }

        const footer = (
            <SearchBox
                className="mx_MemberList_query mx_textinput_icon mx_textinput_search"
                placeholder={_t("member_list|filter_placeholder")}
                onSearch={this.onSearchQueryChanged}
                initialValue={this.props.searchQuery}
            />
        );

        return (
            <BaseCard
                id="memberlist-panel"
                className="mx_MemberList"
                ariaLabelledBy="memberlist-panel-tab"
                role="tabpanel"
                header={_t("common|people")}
                footer={footer}
                onClose={this.props.onClose}
            >
                {inviteButton}
                <div className="mx_MemberList_wrapper">
                    <TruncatedList
                        className="mx_MemberList_section mx_MemberList_joined"
                        truncateAt={this.state.truncateAtJoined}
                        createOverflowElement={this.createOverflowTileJoined}
                        getChildren={this.getChildrenJoined}
                        getChildCount={this.getChildCountJoined}
                    />
                    {invitedHeader}
                    {invitedSection}
                </div>
            </BaseCard>
        );
    }

    private onInviteButtonClick = (ev: ButtonEvent): void => {
        PosthogTrackers.trackInteraction("WebRightPanelMemberListInviteButton", ev);

        const cli = MatrixClientPeg.safeGet();
        const room = cli.getRoom(this.props.roomId)!;

        inviteToRoom(room);
    };
}
