import asyncio
import logging
import os
from typing import Optional

from dotenv import dotenv_values

from olvid import listeners, errors, OlvidClient

from ClientHolder import ClientHolder, datatypes, ClientWrapper
from utils.KeycloakManagerApiClient import KeycloakManagerApiClient


class KeycloakHolder:
	class KeycloakIdentity:
		def __init__(self, identity: datatypes.Identity, client: ClientWrapper, keycloak_user_id: str):
			self.olvid_client: ClientWrapper = client
			self.identity: datatypes.Identity = identity
			self.keycloak_id: str = keycloak_user_id

		async def update_identity(self):
			identity: datatypes.Identity = await self.olvid_client.identity_get()
			self.identity = identity
			self.olvid_client.identity = identity

	def __init__(self):
		config: dict[str, str] = {
			**dotenv_values(),  # .env file values
			**os.environ  # env values
		}

		self.server_url: str = config.get("TEST_KEYCLOAK_SERVER_URL")
		self.admin_realm_name: str = config.get("TEST_KEYCLOAK_ADMIN_REALM_NAME")
		self.username: str = config.get("TEST_KEYCLOAK_USERNAME")
		self.password: str = config.get("TEST_KEYCLOAK_PASSWORD")
		self.client_id: str = config.get("TEST_KEYCLOAK_CLIENT_ID")
		self.user_realm_name: str = config.get("TEST_KEYCLOAK_USER_REALM_NAME")
		assert self.server_url is not None, "TEST_KEYCLOAK_SERVER_URL not set"
		assert self.admin_realm_name is not None, "TEST_KEYCLOAK_ADMIN_REALM_NAME not set"
		assert self.username is not None, "TEST_KEYCLOAK_USERNAME not set"
		assert self.password is not None, "TEST_KEYCLOAK_PASSWORD not set"
		assert self.client_id is not None, "TEST_KEYCLOAK_CLIENT_ID not set"
		assert self.user_realm_name is not None, "TEST_KEYCLOAK_USER_REALM_NAME not set"

		self.kc_admin_client: KeycloakManagerApiClient = KeycloakManagerApiClient(self.server_url, self.client_id, self.admin_realm_name, self.username, self.password)
		self.identities: list[KeycloakHolder.KeycloakIdentity] = []
		self.user_realm_name: str = self.user_realm_name


async def create_keycloak_bots(kc_holder: KeycloakHolder, client_holder: ClientHolder) -> None:
	for i in range(len(client_holder.clients)):
		username: str = f"TestIdentity-{client_holder.random_prefix}-KC-{i}"
		identity_details: datatypes.IdentityDetails = datatypes.IdentityDetails(first_name=username, last_name=str(i), position="TO DEL", company="Earth")
		new_identity: KeycloakHolder.KeycloakIdentity = await new_keycloak_bot(kc_holder, client_holder, username, identity_details)
		kc_holder.identities.append(new_identity)


async def new_keycloak_bot(kc_holder: KeycloakHolder, client_holder: ClientHolder, username: str, identity_details: datatypes.IdentityDetails) -> KeycloakHolder.KeycloakIdentity:
	magic_link: str = await kc_holder.kc_admin_client.create_bot(realm_name=kc_holder.user_realm_name, username=username, identity_details=identity_details)
	# create a new identity with an associated client and check association with keycloak
	new_identity: datatypes.Identity = await client_holder.admin_client.admin_identity_keycloak_new(configuration_link=magic_link)
	new_identity_key: datatypes.ClientKey = await client_holder.admin_client.admin_client_key_new(name="test-keycloak-bot-key", identity_id=new_identity.id)
	new_client: ClientWrapper = ClientWrapper(identity=new_identity, client_key=new_identity_key)

	users_list = await kc_holder.kc_admin_client.get_user_by_username(realm_name=kc_holder.user_realm_name, username=username)
	keycloak_user_id: str = users_list.get("usersList")[0].get("id")

	logging.info(f"created keycloak bot: {new_identity.id}: {new_identity.display_name}")
	# add new identity to keycloak holder
	return KeycloakHolder.KeycloakIdentity(new_identity, new_client, keycloak_user_id)


async def clean(kc_holder: KeycloakHolder, client_holder: ClientHolder) -> None:
	for kc_identity in kc_holder.identities:
		await delete_keycloak_bot(kc_holder, client_holder, kc_identity)


async def delete_keycloak_bot(kc_holder: KeycloakHolder, client_holder: ClientHolder, kc_identity: KeycloakHolder.KeycloakIdentity) -> None:
	# delete identity on keycloak
	await kc_holder.kc_admin_client.delete_user(realm_name=kc_holder.user_realm_name, user_id=kc_identity.keycloak_id)
	# delete identity on daemon
	await client_holder.admin_client.admin_identity_delete(identity_id=kc_identity.identity.id, delete_everywhere=True)


async def test_identity_auth(kc_holder: KeycloakHolder, kc_identity: KeycloakHolder.KeycloakIdentity):
	# force refresh token
	_ = [ul async for ul, _ in kc_identity.olvid_client.keycloak_user_list()]

	# log out bot user on server
	await kc_holder.kc_admin_client._admin_api_request({"q": 13, "realmName": kc_holder.user_realm_name, "id": kc_identity.keycloak_id})

	# try to re-connect to server using identity auth
	_ = [ul async for ul, _ in kc_identity.olvid_client.keycloak_user_list()]


async def test_kc_contact_list(kc_holder: KeycloakHolder):
	all_user_ids: list[list[str]] = []
	for kc_identity in kc_holder.identities:
		user_ids: list[str] = [u.keycloak_id async for users, _ in kc_identity.olvid_client.keycloak_user_list() for u in users]
		user_ids.append(kc_identity.keycloak_id)
		all_user_ids.append(sorted(user_ids))
	for i in range(len(all_user_ids) - 1):
		for a, b in zip(all_user_ids[i], all_user_ids[i + 1]):
			assert a == b, "keycloak user ids are not coherent"

async def test_kc_contact_add(kc_holder: KeycloakHolder, client_holder: ClientHolder, kc_identity: KeycloakHolder.KeycloakIdentity):
	# add only identities from this test, cause we need them to accept the invitation
	async for kc_users, _ in kc_identity.olvid_client.keycloak_user_list(filter=datatypes.KeycloakUserFilter(contact=datatypes.KeycloakUserFilter.Contact.CONTACT_IS_NOT, display_name_search=client_holder.random_prefix)):
		for kc_user in kc_users:
			other_identities: list[KeycloakHolder.KeycloakIdentity] = [i for i in kc_holder.identities if i.keycloak_id == kc_user.keycloak_id]
			# this identity
			if len(other_identities) == 0:
				continue
			assert len(other_identities) == 1, "Found more than one identity for the same user id"
			other_identity: KeycloakHolder.KeycloakIdentity = other_identities[0]
			await kc_identity.olvid_client.keycloak_add_user_as_contact(keycloak_id=kc_user.keycloak_id)
			bots = [
				# inviter
				kc_identity.olvid_client.create_notification_bot(listeners.ContactNewListener(handler=kc_identity.olvid_client.get_check_content_handler(datatypes.Contact(display_name=kc_user.display_name, details=kc_user.details, has_one_to_one_discussion=True, keycloak_managed=True), notification_type=listeners.NOTIFICATIONS.CONTACT_NEW), count=1)),
				kc_identity.olvid_client.create_notification_bot(listeners.DiscussionNewListener(handler=kc_identity.olvid_client.get_check_content_handler(datatypes.Discussion(title=kc_user.display_name), notification_type=listeners.NOTIFICATIONS.DISCUSSION_NEW), count=1)),
				# invite
				other_identity.olvid_client.create_notification_bot(listeners.ContactNewListener(handler=other_identity.olvid_client.get_check_content_handler(datatypes.Contact(display_name=kc_identity.identity.display_name, details=kc_identity.identity.details, has_one_to_one_discussion=True, keycloak_managed=True), notification_type=listeners.NOTIFICATIONS.CONTACT_NEW), count=1)),
				other_identity.olvid_client.create_notification_bot(listeners.DiscussionNewListener(handler=other_identity.olvid_client.get_check_content_handler(datatypes.Discussion(title=kc_identity.identity.display_name), notification_type=listeners.NOTIFICATIONS.DISCUSSION_NEW), count=1)),
			]
			for bot in bots:
				await bot.wait_for_listeners_end()
			logging.debug(f"{kc_identity.identity.id}: added {kc_user.display_name} as a contact")


async def test_overwrite_identity(kc_holder: KeycloakHolder, client_holder: ClientHolder):
	# create one bot
	kc_identity_1: KeycloakHolder.KeycloakIdentity = await new_keycloak_bot(kc_holder=kc_holder, client_holder=client_holder, username=f"{client_holder.random_prefix}-test-overwrite-1", identity_details=datatypes.IdentityDetails(first_name=f"{client_holder.random_prefix}-test-overwrite-1", last_name="todel"))
	olvid_identity_2: Optional[datatypes.Identity] = None
	try:
		####
		# Upload a new bot identity on keycloak and check previous bot is no longer keycloak managed
		####
		# create a new olvid identity and associated client
		olvid_identity_2 = await client_holder.admin_client.admin_identity_new(identity_details=datatypes.IdentityDetails(first_name=f"{client_holder.random_prefix}-test-overwrite-2", last_name="todel"))
		olvid_client_key_2 = await client_holder.admin_client.admin_client_key_new("test-key-todel", olvid_identity_2.id)
		olvid_client_2 = OlvidClient(client_key=olvid_client_key_2.key)

		# bind new user with a new magic link
		magic_link = await kc_holder.kc_admin_client.reset_bot_link(kc_holder.user_realm_name, kc_identity_1.keycloak_id)
		await olvid_client_2.keycloak_bind_identity(configuration_link=magic_link)

		# wait for binding to be effective
		count: int = 0
		while count < 10:
			await asyncio.sleep(0.5)
			if (await olvid_client_2.identity_get()).keycloak_managed is True:
				break
			count += 1
		assert (await olvid_client_2.identity_get()).keycloak_managed is True, "Identity binding failed"

		# check 2 can access keycloak
		_ = [_ async for _ in olvid_client_2.keycloak_user_list()]

		# check if 1 have been revoked
		try:
			_ = [_ async for _ in kc_identity_1.olvid_client.keycloak_user_list()]
			assert False, "first identity was not revoked"
		except errors.InternalError as e:
			assert str(e) == "InternalError: Invalid keycloak server response (RFC_IDENTITY_REVOKED)", f"Error code invalid: {str(e)}"

		# check 1 is no longer keycloak managed
		count: int = 0
		while count < 10:
			await asyncio.sleep(0.5)
			if (await kc_identity_1.olvid_client.identity_get()).keycloak_managed is False:
				break
			count += 1
		assert (await kc_identity_1.olvid_client.identity_get()).keycloak_managed is False, "Identity is still keycloak managed"

		####
		# Re-bind first identity on keycloak, before deleting other identity
		####
		# create a new magic link for first keycloak user
		magic_link = await kc_holder.kc_admin_client.reset_bot_link(kc_holder.user_realm_name, kc_identity_1.keycloak_id)
		await kc_identity_1.olvid_client.keycloak_bind_identity(configuration_link=magic_link)

		# wait for binding to be effective
		count: int = 0
		while count < 10:
			await asyncio.sleep(0.5)
			if (await kc_identity_1.olvid_client.identity_get()).keycloak_managed is True:
				break
			count += 1
		assert (await kc_identity_1.olvid_client.identity_get()).keycloak_managed is True, "Identity re-binding failed"

		# check 1 can access keycloak
		_ = [_ async for _ in kc_identity_1.olvid_client.keycloak_user_list()]
	finally:
		await delete_keycloak_bot(kc_holder, client_holder, kc_identity_1)
		if olvid_identity_2:
			await client_holder.admin_client.admin_identity_delete(identity_id=olvid_identity_2.id)


async def test_kc_reset_link(kc_holder: KeycloakHolder):
	identity: KeycloakHolder.KeycloakIdentity = kc_holder.identities[0]
	identity_will_updated: bool = identity.identity.details.position or identity.identity.details.company

	bots: list[OlvidClient] = []

	# if identity have a company or a position details will be updated by keycloak
	if identity_will_updated:
		for other_identity in kc_holder.identities:
			try:
				other_contact = await other_identity.olvid_client.get_contact_associated_to_another_client(identity.olvid_client)
				other_contact_original_details = other_contact.details._clone()
				other_discussion = await other_contact.get_discussion(other_identity.olvid_client)
				other_contact.display_name = ""
				other_contact.details.position = ""
				other_contact.details.company = ""
				other_contact.keycloak_managed = False
				bots.append(other_identity.olvid_client.create_notification_bot(listeners.ContactDetailsUpdatedListener(handler=other_identity.olvid_client.get_check_content_handler(other_contact, other_contact_original_details, notification_type=listeners.NOTIFICATIONS.CONTACT_DETAILS_UPDATED), count=1)))
				bots.append(other_identity.olvid_client.create_notification_bot(listeners.DiscussionTitleUpdatedListener(handler=other_identity.olvid_client.get_check_content_handler(datatypes.Discussion(id=other_discussion.id), other_discussion.title, notification_type=listeners.NOTIFICATIONS.DISCUSSION_TITLE_UPDATED), count=1)))
			# other contact not found
			except Exception as e:
				assert str(e) == "Contact not found"

	# reset link and unbind from keycloak
	configuration_link: str = await kc_holder.kc_admin_client.reset_bot_link(realm_name=kc_holder.user_realm_name, user_id=identity.keycloak_id)
	await identity.olvid_client.keycloak_unbind_identity()

	# check that identity cannot connect to keycloak
	try:
		_ = [_ async for _ in identity.olvid_client.keycloak_user_list()]
		assert False, "Identity was not properly revoked"
	except errors.InternalError as e:
		assert str(e) == "InternalError: Invalid keycloak server response (RFC_IDENTITY_NOT_MANAGED)", f"Error code invalid: {str(e)}"
	except errors.InvalidArgumentError as e:
		assert str(e) == "InvalidArgumentError: Identity is not keycloak managed", f"Error code invalid: {str(e)}"

	# wait for identity no to be keycloak managed
	async with asyncio.timeout(5):
		while (await identity.olvid_client.identity_get()).keycloak_managed is True:
			await asyncio.sleep(0.1)

	# check contact details update
	for bot in bots:
		await bot.wait_for_listeners_end()

	# check contact is not keycloak managed for other identities
	for other_identity in kc_holder.identities[1:]:
		other_contact = await other_identity.olvid_client.get_contact_associated_to_another_client(identity.olvid_client)
		async with asyncio.timeout(10):
			while (await other_identity.olvid_client.contact_get(contact_id=other_contact.id)).keycloak_managed is True:
				await asyncio.sleep(0.1)

	# re-bind to keycloak
	await identity.olvid_client.keycloak_bind_identity(configuration_link=configuration_link)

	# wait for identity to be keycloak managed again
	async with asyncio.timeout(5):
		while (await identity.olvid_client.identity_get()).keycloak_managed is False:
			await asyncio.sleep(0.1)

	# if identity have a company or a position details will be updated by keycloak
	if identity_will_updated:
		for other_identity in kc_holder.identities:
			try:
				other_contact = await other_identity.olvid_client.get_contact_associated_to_another_client(identity.olvid_client)
				other_discussion = await other_contact.get_discussion(other_identity.olvid_client)
				other_contact.display_name = ""
				other_contact.details.position = ""
				other_contact.details.company = ""
				other_contact.keycloak_managed = False
				bots.append(other_identity.olvid_client.create_notification_bot(listeners.ContactDetailsUpdatedListener(handler=other_identity.olvid_client.get_check_content_handler(datatypes.Contact(id=other_contact.id), other_contact.details, notification_type=listeners.NOTIFICATIONS.CONTACT_DETAILS_UPDATED), count=1)))
				bots.append(other_identity.olvid_client.create_notification_bot(listeners.DiscussionTitleUpdatedListener(handler=other_identity.olvid_client.get_check_content_handler(datatypes.Discussion(id=other_discussion.id), other_discussion.title, notification_type=listeners.NOTIFICATIONS.DISCUSSION_TITLE_UPDATED), count=1)))
			# other contact not found
			except Exception as e:
				assert str(e) == "Contact not found"
	for bot in bots:
		await bot.wait_for_listeners_end()
	# update identity value
	await identity.update_identity()


async def test_kc_revoke_an_identity(kc_holder: KeycloakHolder):
	identity: KeycloakHolder.KeycloakIdentity = kc_holder.identities[0]

	# wait for contact details to be updated by keycloak (position and company will be removed for contacts)
	bots: list[OlvidClient] = []
	if identity.identity.details.company or identity.identity.details.position:
		for other_identity in kc_holder.identities[1:]:
			other_contact = await other_identity.olvid_client.get_contact_associated_to_another_client(identity.olvid_client)
			if other_contact:
				other_discussion = await other_contact.get_discussion(other_identity.olvid_client)
				other_contact.display_name = ""
				other_contact.details.position = ""
				other_contact.details.company = ""
				other_contact.keycloak_managed = False
				bots.append(other_identity.olvid_client.create_notification_bot(listeners.ContactDetailsUpdatedListener(handler=other_identity.olvid_client.get_check_content_handler(other_contact, identity.identity.details, notification_type=listeners.NOTIFICATIONS.CONTACT_DETAILS_UPDATED), count=1)))
				bots.append(other_identity.olvid_client.create_notification_bot(listeners.DiscussionTitleUpdatedListener(handler=other_identity.olvid_client.get_check_content_handler(datatypes.Discussion(id=other_discussion.id), identity.identity.display_name, notification_type=listeners.NOTIFICATIONS.DISCUSSION_TITLE_UPDATED), count=1)))

	# revoke first identity
	await kc_holder.kc_admin_client.delete_user(realm_name=kc_holder.user_realm_name, user_id=identity.keycloak_id)

	# check that identity cannot connect to keycloak
	try:
		_ = [_ async for _ in identity.olvid_client.keycloak_user_list()]
		assert False, "Identity was not properly revoked"
	except errors.InternalError as e:
		assert str(e) == "InternalError: Invalid keycloak server response (RFC_IDENTITY_REVOKED)", f"Error code invalid: {str(e)}"

	for bot in bots:
		await bot.wait_for_listeners_end()

	# wait for identity not to be keycloak managed
	count: int = 0
	while count < 10:
		await asyncio.sleep(0.5)
		if (await identity.olvid_client.identity_get()).keycloak_managed is False:
			break
		count += 1
	assert (await identity.olvid_client.identity_get()).keycloak_managed is False, "Identity is still keycloak managed"
	# update identity value
	await identity.update_identity()

	# check contact is not keycloak managed for other identities
	for other_identity in kc_holder.identities[1:]:
		other_contact = await other_identity.olvid_client.get_contact_associated_to_another_client(identity.olvid_client)
		assert (await other_identity.olvid_client.contact_get(contact_id=other_contact.id)).keycloak_managed is False, "Contact is still keycloak managed"


async def test_settings_auto_invite(kc_holder: KeycloakHolder, client_holder: ClientHolder):
	# create a new keycloak managed identity
	kc_identity: KeycloakHolder.KeycloakIdentity = await new_keycloak_bot(kc_holder=kc_holder, client_holder=client_holder, username=f"{client_holder.random_prefix}-test-settings-1", identity_details=datatypes.IdentityDetails(first_name=f"{client_holder.random_prefix}-test-settings-1", last_name="todel"))

	# add listeners for this new identity (we cannot be sure every other identity will accept, so we will add a timeout)
	new_contact_count: int = sum([len(users) async for users, _ in kc_identity.olvid_client.keycloak_user_list(filter=datatypes.KeycloakUserFilter(contact=datatypes.KeycloakUserFilter.Contact.CONTACT_IS_NOT))])
	new_contact_bots = [
		kc_identity.olvid_client.create_notification_bot(listeners.ContactNewListener(handler=lambda c: c, count=new_contact_count)),
		kc_identity.olvid_client.create_notification_bot(listeners.DiscussionNewListener(handler=lambda d: d, count=new_contact_count)),
	]

	# add listeners for other keycloak managed identities
	bots: list[OlvidClient] = []
	for other_kc_identity in kc_holder.identities:
		other_client: ClientWrapper = other_kc_identity.olvid_client
		if (await other_client.identity_get()).keycloak_managed is False:
			logging.warning(f"Skipping non managed keycloak identity: {other_kc_identity.identity.id}")
			continue

		bots.extend([
			other_client.create_notification_bot(
				listeners.ContactNewListener(handler=other_client.get_check_content_handler(datatypes.Contact(display_name=kc_identity.identity.display_name, details=kc_identity.identity.details, has_one_to_one_discussion=True, keycloak_managed=True), notification_type=listeners.NOTIFICATIONS.CONTACT_NEW), filter=datatypes.ContactFilter(display_name_search=kc_identity.identity.display_name), count=1),
			),
			other_client.create_notification_bot(
				listeners.DiscussionNewListener(handler=other_client.get_check_content_handler(datatypes.Discussion(title=kc_identity.identity.display_name), notification_type=listeners.NOTIFICATIONS.DISCUSSION_NEW), filter=datatypes.DiscussionFilter(title_search=kc_identity.identity.display_name), count=1),
			),
		])

	# change settings to auto invite other keycloak members
	new_identity_settings: datatypes.IdentitySettings = await kc_identity.olvid_client.settings_identity_get()
	new_identity_settings.keycloak.auto_invite_new_members = True
	await kc_identity.olvid_client.settings_identity_set(new_identity_settings)

	# wait for other identities bots
	for bot in bots:
		await bot.wait_for_listeners_end()
	# timeout on this new identity bots (we might have deleted identities that won't respond to invitations)
	try:
		async with asyncio.timeout(5):
			logging.info("Wait for other keycloak contact to accept invitations")
			for bot in new_contact_bots:
				await bot.wait_for_listeners_end()
	except asyncio.TimeoutError:
		logging.info("Some Keycloak contacts have not accepted invitations and might be inactive.")
	for bot in new_contact_bots:
		await bot.stop()

	# delete newly created identity, and wait for other identities to remove the contact
	bots = []
	for other_kc_identity in kc_holder.identities:
		other_contact: datatypes.Contact = await other_kc_identity.olvid_client.get_contact_associated_to_another_client(kc_identity.olvid_client)
		other_discussion: datatypes.Discussion = await other_contact.get_discussion(other_kc_identity.olvid_client)
		bots.extend([
			other_kc_identity.olvid_client.create_notification_bot(listeners.ContactDeletedListener(handler=other_kc_identity.olvid_client.get_check_content_handler(datatypes.Contact(id=other_contact.id, display_name=kc_identity.identity.display_name, details=kc_identity.identity.details), notification_type=listeners.NOTIFICATIONS.CONTACT_DELETED), count=1)),
			other_kc_identity.olvid_client.create_notification_bot(listeners.DiscussionLockedListener(handler=other_kc_identity.olvid_client.get_check_content_handler(datatypes.Discussion(id=other_discussion.id, title=kc_identity.identity.display_name), notification_type=listeners.NOTIFICATIONS.DISCUSSION_LOCKED), count=1)),
		])

	await delete_keycloak_bot(kc_holder=kc_holder, client_holder=client_holder, kc_identity=kc_identity)

	for bot in bots:
		await bot.wait_for_listeners_end()


async def test_keycloak(client_holder: ClientHolder) -> Optional[KeycloakHolder]:
	# we need admin accesses to olvid console for keycloak to run those tests.
	try:
		kc_holder: KeycloakHolder = KeycloakHolder()
		await kc_holder.kc_admin_client.who_am_i()
	except AssertionError as e:
		logging.error(f"Keycloak manager identity not set in env, skipping test ({e})")
		return None
	except Exception:
		logging.exception("Cannot run keycloak tests")
		return None

	# create bot identities on keycloak to run tests
	await create_keycloak_bots(kc_holder=kc_holder, client_holder=client_holder)

	logging.info(f"test keycloak contact listing")
	await test_kc_contact_list(kc_holder)
	for kc_identity in kc_holder.identities:
		logging.info(f"test keycloak contact add")
		await test_kc_contact_add(kc_holder, client_holder, kc_identity)
		logging.info(f"{kc_identity.identity.id}: test identity authentication")
		await test_identity_auth(kc_holder, kc_identity)

	logging.info(f"test reset link")
	await test_kc_reset_link(kc_holder)
	logging.info(f"test auto invite")
	await test_settings_auto_invite(kc_holder, client_holder)
	logging.info(f"test identity overwrite")
	await test_overwrite_identity(kc_holder, client_holder)

	# run last because first identity will be revoked and won't be re-usable
	logging.info(f"test identity revocation")
	await test_kc_revoke_an_identity(kc_holder)

	return kc_holder
