from unittest.mock import AsyncMock, patch

from twisted.test.proto_helpers import MemoryReactor

import synapse.rest.admin
import synapse.rest.client.login
import synapse.rest.client.room
from synapse.api.constants import EventTypes, Membership
from synapse.api.errors import Codes, LimitExceededError, SynapseError
from synapse.crypto.event_signing import add_hashes_and_signatures
from synapse.events import FrozenEventV3
from synapse.federation.federation_client import SendJoinResult
from synapse.server import HomeServer
from synapse.types import UserID, create_requester
from synapse.util import Clock

from tests.replication._base import BaseMultiWorkerStreamTestCase
from tests.server import make_request
from tests.unittest import (
    FederatingHomeserverTestCase,
    HomeserverTestCase,
    override_config,
)


class TestJoinsLimitedByPerRoomRateLimiter(FederatingHomeserverTestCase):
    servlets = [
        synapse.rest.admin.register_servlets,
        synapse.rest.client.login.register_servlets,
        synapse.rest.client.room.register_servlets,
    ]

    def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
        self.handler = hs.get_room_member_handler()

        # Create three users.
        self.alice = self.register_user("alice", "pass")
        self.alice_token = self.login("alice", "pass")
        self.bob = self.register_user("bob", "pass")
        self.bob_token = self.login("bob", "pass")
        self.chris = self.register_user("chris", "pass")
        self.chris_token = self.login("chris", "pass")

        # Create a room on this homeserver. Note that this counts as a join: it
        # contributes to the rate limter's count of actions
        self.room_id = self.helper.create_room_as(self.alice, tok=self.alice_token)

        self.intially_unjoined_room_id = f"!example:{self.OTHER_SERVER_NAME}"

    @override_config({"rc_joins_per_room": {"per_second": 0, "burst_count": 2}})
    def test_local_user_local_joins_contribute_to_limit_and_are_limited(self) -> None:
        # The rate limiter has accumulated one token from Alice's join after the create
        # event.
        # Try joining the room as Bob.
        self.get_success(
            self.handler.update_membership(
                requester=create_requester(self.bob),
                target=UserID.from_string(self.bob),
                room_id=self.room_id,
                action=Membership.JOIN,
            )
        )

        # The rate limiter bucket is full. A second join should be denied.
        self.get_failure(
            self.handler.update_membership(
                requester=create_requester(self.chris),
                target=UserID.from_string(self.chris),
                room_id=self.room_id,
                action=Membership.JOIN,
            ),
            LimitExceededError,
            by=0.5,
        )

    @override_config({"rc_joins_per_room": {"per_second": 0, "burst_count": 2}})
    def test_local_user_profile_edits_dont_contribute_to_limit(self) -> None:
        # The rate limiter has accumulated one token from Alice's join after the create
        # event. Alice should still be able to change her displayname.
        self.get_success(
            self.handler.update_membership(
                requester=create_requester(self.alice),
                target=UserID.from_string(self.alice),
                room_id=self.room_id,
                action=Membership.JOIN,
                content={"displayname": "Alice Cooper"},
            )
        )

        # Still room in the limiter bucket. Chris's join should be accepted.
        self.get_success(
            self.handler.update_membership(
                requester=create_requester(self.chris),
                target=UserID.from_string(self.chris),
                room_id=self.room_id,
                action=Membership.JOIN,
            )
        )

    @override_config({"rc_joins_per_room": {"per_second": 0, "burst_count": 1}})
    def test_remote_joins_contribute_to_rate_limit(self) -> None:
        # Join once, to fill the rate limiter bucket.
        #
        # To do this we have to mock the responses from the remote homeserver.
        # We also patch out a bunch of event checks on our end. All we're really
        # trying to check here is that remote joins will bump the rate limter when
        # they are persisted.
        create_event_source = {
            "auth_events": [],
            "content": {
                "creator": f"@creator:{self.OTHER_SERVER_NAME}",
                "room_version": self.hs.config.server.default_room_version.identifier,
            },
            "depth": 0,
            "origin_server_ts": 0,
            "prev_events": [],
            "room_id": self.intially_unjoined_room_id,
            "sender": f"@creator:{self.OTHER_SERVER_NAME}",
            "state_key": "",
            "type": EventTypes.Create,
        }
        self.add_hashes_and_signatures_from_other_server(
            create_event_source,
            self.hs.config.server.default_room_version,
        )
        create_event = FrozenEventV3(
            create_event_source,
            self.hs.config.server.default_room_version,
            {},
            None,
        )

        join_event_source = {
            "auth_events": [create_event.event_id],
            "content": {"membership": "join"},
            "depth": 1,
            "origin_server_ts": 100,
            "prev_events": [create_event.event_id],
            "sender": self.bob,
            "state_key": self.bob,
            "room_id": self.intially_unjoined_room_id,
            "type": EventTypes.Member,
        }
        add_hashes_and_signatures(
            self.hs.config.server.default_room_version,
            join_event_source,
            self.hs.hostname,
            self.hs.signing_key,
        )
        join_event = FrozenEventV3(
            join_event_source,
            self.hs.config.server.default_room_version,
            {},
            None,
        )

        mock_make_membership_event = AsyncMock(
            return_value=(
                self.OTHER_SERVER_NAME,
                join_event,
                self.hs.config.server.default_room_version,
            )
        )
        mock_send_join = AsyncMock(
            return_value=SendJoinResult(
                join_event,
                self.OTHER_SERVER_NAME,
                state=[create_event],
                auth_chain=[create_event],
                partial_state=False,
                servers_in_room=frozenset(),
            )
        )

        with (
            patch.object(
                self.handler.federation_handler.federation_client,
                "make_membership_event",
                mock_make_membership_event,
            ),
            patch.object(
                self.handler.federation_handler.federation_client,
                "send_join",
                mock_send_join,
            ),
            patch(
                "synapse.event_auth._is_membership_change_allowed",
                return_value=None,
            ),
            patch(
                "synapse.handlers.federation_event.check_state_dependent_auth_rules",
                return_value=None,
            ),
        ):
            self.get_success(
                self.handler.update_membership(
                    requester=create_requester(self.bob),
                    target=UserID.from_string(self.bob),
                    room_id=self.intially_unjoined_room_id,
                    action=Membership.JOIN,
                    remote_room_hosts=[self.OTHER_SERVER_NAME],
                )
            )

            # Try to join as Chris. Should get denied.
            self.get_failure(
                self.handler.update_membership(
                    requester=create_requester(self.chris),
                    target=UserID.from_string(self.chris),
                    room_id=self.intially_unjoined_room_id,
                    action=Membership.JOIN,
                    remote_room_hosts=[self.OTHER_SERVER_NAME],
                ),
                LimitExceededError,
                by=0.5,
            )

    # TODO: test that remote joins to a room are rate limited.
    #   Could do this by setting the burst count to 1, then:
    #   - remote-joining a room
    #   - immediately leaving
    #   - trying to remote-join again.


class TestReplicatedJoinsLimitedByPerRoomRateLimiter(BaseMultiWorkerStreamTestCase):
    servlets = [
        synapse.rest.admin.register_servlets,
        synapse.rest.client.login.register_servlets,
        synapse.rest.client.room.register_servlets,
    ]

    def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
        self.handler = hs.get_room_member_handler()

        # Create three users.
        self.alice = self.register_user("alice", "pass")
        self.alice_token = self.login("alice", "pass")
        self.bob = self.register_user("bob", "pass")
        self.bob_token = self.login("bob", "pass")
        self.chris = self.register_user("chris", "pass")
        self.chris_token = self.login("chris", "pass")

        # Create a room on this homeserver.
        # Note that this counts as a
        self.room_id = self.helper.create_room_as(self.alice, tok=self.alice_token)
        self.intially_unjoined_room_id = "!example:otherhs"

    @override_config({"rc_joins_per_room": {"per_second": 0, "burst_count": 2}})
    def test_local_users_joining_on_another_worker_contribute_to_rate_limit(
        self,
    ) -> None:
        # The rate limiter has accumulated one token from Alice's join after the create
        # event.
        self.replicate()

        # Spawn another worker and have bob join via it.
        worker_app = self.make_worker_hs(
            "synapse.app.generic_worker", extra_config={"worker_name": "other worker"}
        )
        worker_site = self._hs_to_site[worker_app]
        channel = make_request(
            self.reactor,
            worker_site,
            "POST",
            f"/_matrix/client/v3/rooms/{self.room_id}/join",
            access_token=self.bob_token,
        )
        self.assertEqual(channel.code, 200, channel.json_body)

        # wait for join to arrive over replication
        self.replicate()

        # Try to join as Chris on the worker. Should get denied because Alice
        # and Bob have both joined the room.
        self.get_failure(
            worker_app.get_room_member_handler().update_membership(
                requester=create_requester(self.chris),
                target=UserID.from_string(self.chris),
                room_id=self.room_id,
                action=Membership.JOIN,
            ),
            LimitExceededError,
            by=0.5,
        )

        # Try to join as Chris on the original worker. Should get denied because Alice
        # and Bob have both joined the room.
        self.get_failure(
            self.handler.update_membership(
                requester=create_requester(self.chris),
                target=UserID.from_string(self.chris),
                room_id=self.room_id,
                action=Membership.JOIN,
            ),
            LimitExceededError,
            by=0.5,
        )


class RoomMemberMasterHandlerTestCase(HomeserverTestCase):
    servlets = [
        synapse.rest.admin.register_servlets,
        synapse.rest.client.login.register_servlets,
        synapse.rest.client.room.register_servlets,
    ]

    def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
        self.handler = hs.get_room_member_handler()
        self.store = hs.get_datastores().main

        # Create two users.
        self.alice = self.register_user("alice", "pass")
        self.alice_ID = UserID.from_string(self.alice)
        self.alice_token = self.login("alice", "pass")
        self.bob = self.register_user("bob", "pass")
        self.bob_ID = UserID.from_string(self.bob)
        self.bob_token = self.login("bob", "pass")

        # Create a room on this homeserver.
        self.room_id = self.helper.create_room_as(self.alice, tok=self.alice_token)

    def test_leave_and_forget(self) -> None:
        """Tests that forget a room is successfully. The test is performed with two users,
        as forgetting by the last user respectively after all users had left the
        is a special edge case."""
        self.helper.join(self.room_id, user=self.bob, tok=self.bob_token)

        # alice is not the last room member that leaves and forgets the room
        self.helper.leave(self.room_id, user=self.alice, tok=self.alice_token)
        self.get_success(self.handler.forget(self.alice_ID, self.room_id))
        self.assertTrue(
            self.get_success(self.store.did_forget(self.alice, self.room_id))
        )

        # the server has not forgotten the room
        self.assertFalse(
            self.get_success(self.store.is_locally_forgotten_room(self.room_id))
        )

    def test_leave_and_unforget(self) -> None:
        """Tests if rejoining a room unforgets the room, so that it shows up in sync again."""
        self.helper.join(self.room_id, user=self.bob, tok=self.bob_token)

        # alice is not the last room member that leaves and forgets the room
        self.helper.leave(self.room_id, user=self.alice, tok=self.alice_token)
        self.get_success(self.handler.forget(self.alice_ID, self.room_id))
        self.assertTrue(
            self.get_success(self.store.did_forget(self.alice, self.room_id))
        )

        self.helper.join(self.room_id, user=self.alice, tok=self.alice_token)
        self.assertFalse(
            self.get_success(self.store.did_forget(self.alice, self.room_id))
        )

        # the server has not forgotten the room
        self.assertFalse(
            self.get_success(self.store.is_locally_forgotten_room(self.room_id))
        )

    @override_config({"forget_rooms_on_leave": True})
    def test_leave_and_auto_forget(self) -> None:
        """Tests the `forget_rooms_on_leave` config option."""
        self.helper.join(self.room_id, user=self.bob, tok=self.bob_token)

        # alice is not the last room member that leaves and forgets the room
        self.helper.leave(self.room_id, user=self.alice, tok=self.alice_token)
        self.assertTrue(
            self.get_success(self.store.did_forget(self.alice, self.room_id))
        )

    def test_leave_and_forget_last_user(self) -> None:
        """Tests that forget a room is successfully when the last user has left the room."""

        # alice is the last room member that leaves and forgets the room
        self.helper.leave(self.room_id, user=self.alice, tok=self.alice_token)
        self.get_success(self.handler.forget(self.alice_ID, self.room_id))
        self.assertTrue(
            self.get_success(self.store.did_forget(self.alice, self.room_id))
        )

        # the server has forgotten the room
        self.assertTrue(
            self.get_success(self.store.is_locally_forgotten_room(self.room_id))
        )

    def test_forget_when_not_left(self) -> None:
        """Tests that a user cannot forget a room that they are still in."""
        self.get_failure(self.handler.forget(self.alice_ID, self.room_id), SynapseError)

    def test_nonlocal_room_user_action(self) -> None:
        """
        Test that non-local user ids cannot perform room actions through
        this homeserver.
        """
        alien_user_id = UserID.from_string("@cheeky_monkey:matrix.org")
        bad_room_id = f"{self.room_id}+BAD_ID"

        exc = self.get_failure(
            self.handler.update_membership(
                create_requester(self.alice),
                alien_user_id,
                bad_room_id,
                "unban",
            ),
            SynapseError,
        ).value

        self.assertEqual(exc.errcode, Codes.BAD_JSON)

    def test_rejoin_forgotten_by_user(self) -> None:
        """Test that a user that has forgotten a room can do a re-join.
        The room was not forgotten from the local server.
        One local user is still member of the room."""
        self.helper.join(self.room_id, user=self.bob, tok=self.bob_token)

        self.helper.leave(self.room_id, user=self.alice, tok=self.alice_token)
        self.get_success(self.handler.forget(self.alice_ID, self.room_id))
        self.assertTrue(
            self.get_success(self.store.did_forget(self.alice, self.room_id))
        )

        # the server has not forgotten the room
        self.assertFalse(
            self.get_success(self.store.is_locally_forgotten_room(self.room_id))
        )

        self.helper.join(self.room_id, user=self.alice, tok=self.alice_token)
        # TODO: A join to a room does not invalidate the forgotten cache
        # see https://github.com/matrix-org/synapse/issues/13262
        self.store.did_forget.invalidate_all()
        self.assertFalse(
            self.get_success(self.store.did_forget(self.alice, self.room_id))
        )

    def test_deduplicate_joins(self) -> None:
        """
        Test that calling /join multiple times does not store a new state group.
        """

        self.helper.join(self.room_id, user=self.bob, tok=self.bob_token)

        sql = "SELECT COUNT(*) FROM state_groups WHERE room_id = ?"
        rows = self.get_success(
            self.store.db_pool.execute("test_deduplicate_joins", sql, self.room_id)
        )
        initial_count = rows[0][0]

        self.helper.join(self.room_id, user=self.bob, tok=self.bob_token)
        rows = self.get_success(
            self.store.db_pool.execute("test_deduplicate_joins", sql, self.room_id)
        )
        new_count = rows[0][0]

        self.assertEqual(initial_count, new_count)
