import asyncio
import random
import struct

from core.backend import Backend, BackendSession, Chat, ChatSession
from core.client import Client
from core.models import LoginOption
from itertools import cycle
from typing import Optional, Callable, Tuple
from util.misc import Logger

from .proto.backend import BackendEventHandler, FOODGROUP_VERSIONS, login, LoginError, bos_cookies
from .proto.snac import OSCARClient, OSCARContext, SNACMessage, foodgroups
from .proto.tlv import unmarshal_tlvs, find_tlv


ROASTING_CHARS = b'\xF3\x26\x81\xC4\x39\x86\xDB\x92\x71\xA3\xB9\xE6\x53\x7A\x95\x7C'


def roast(password: bytes) -> bytes:
	chars = cycle(ROASTING_CHARS)
	return bytes(byte ^ next(chars) for byte in password)


# TODO(subpurple): as the foodgroups are no longer handled just in this file, might want to combine this and entry
class OSCARCtrl:
	logger: Logger
	transport: Optional[asyncio.WriteTransport]

	close_callback: Optional[Callable[[], None]]
	closed: bool

	backend: Backend
	bs: Optional[BackendSession]

	client: Client

	oscarClient: OSCARClient
	context: OSCARContext
	sequence: int = random.randint(0x0000, 0xFFFF)

	def __init__(self, logger: Logger, via: str, backend: Backend) -> None:
		self.logger = logger
		self.transport = None

		self.close_callback = None
		self.closed = False

		self.backend = backend
		self.bs = None
		self.client = Client('aim', '?', via)

		self.context = OSCARContext(self.backend, self.client)
		self.oscarClient = OSCARClient(self)

	def send_specific_frame(self, frame: int, data: bytes) -> None:
		if self.sequence == 0xFFFF:
			self.sequence = 0x0000
		else:
			self.sequence += 1

		packet = b''.join([
			struct.pack('>BBHH', 0x2A, frame, self.sequence, len(data)),
			data
		])

		self.transport.write(packet)

	def send_snac(self, msg: SNACMessage) -> None:
		self.send_specific_frame(0x02, msg.marshal())

	def on_connect(self) -> None:
		self.send_specific_frame(0x01, struct.pack('>L', 1))

	def on_signon_frame(self, data: bytes) -> None:
		if len(data) > 4:
			tlvs = unmarshal_tlvs(data[4:])

			if (cookie_tlv := find_tlv(tlvs, 0x0006)) is not None:
				found = False

				for i, d in enumerate(bos_cookies):
					for cookie in d.items():
						if cookie[0] == cookie_tlv.data:
							if hasattr(cookie[1], 'user') and getattr(cookie[1].user, 'uuid', None) is not None:
								uuid = cookie[1].user.uuid
							elif getattr(cookie[1], 'uuid', None) is not None:
								uuid = cookie[1].uuid
							else:
								uuid = cookie[1]

							self.logger.info('found BOS cookie')

							self.context.bs = self.backend.login(uuid,
																 self.client,
																 BackendEventHandler(self),
																 option=LoginOption.BootOthers)
							self.context.user = self.context.bs.user
							self.bs = self.context.bs

							bos_cookies.pop(i)
							found = True
							break
					if found:
						break

				if found:
					# NINA also sends OSERVICE__WELL_KNOWN_URLS right after (should we?)
					self.logger.info('[Server] OSERVICE__HOST_ONLINE')

					msg = SNACMessage(0x0001, 0x0003)

					for foodgroup in FOODGROUP_VERSIONS.keys():
						msg.write_u16(foodgroup)

					self.send_snac(msg)
					self.bs.front_data['oscar0'] = True
					self.bs.front_data['oscar0_chats'] = {}
				else:
					self.logger.info('invalid BOS cookie given')

			else:
				self.logger.info('Using FLAP-level authentication')

				screen_name_tlv = find_tlv(tlvs, 0x0001)
				roasted_pw_tlv = find_tlv(tlvs, 0x0002)

				screen_name = screen_name_tlv.data.decode()
				roasted_pw = roasted_pw_tlv.data

				self.logger.info('Screen Name (client-given):', screen_name)
				self.logger.info('Password (roasted):', roasted_pw.hex())

				context = self.context
				error_code = None

				if (uuid := context.backend.util_get_uuid_from_username(screen_name)) is None:
					error_code = LoginError.UnregisteredScreenname

					self.logger.info('Unregistered screenname')
				else:
					unroasted_pw = roast(roasted_pw)

					if context.backend.user_service.login_with_username(screen_name, unroasted_pw.decode()) is None:
						error_code = LoginError.IncorrectPassword

						self.logger.info('Incorrect password')

				self.bs.front_data['oscar0'] = True
				self.bs.front_data['oscar0_chats'] = {}
				self.send_specific_frame(0x04, login(self.logger, self.context, tlvs, uuid, error_code))

	def on_data_frame(self, message: SNACMessage) -> None:
		found = False

		# kick client off if we haven't authenticated and client is trying to access something outside of BUCP
		if message.foodgroup != 0x0017 and self.bs is None:
			self.close()
			return

		for value, cls in foodgroups.items():
			if value == message.foodgroup:
				if not hasattr(cls, 'logger'):
					cls.logger = self.logger

				if message.subgroup in cls.subgroups:
					found = True

					func = cls.subgroups[message.subgroup]
					func(cls, self.oscarClient, self.context, message)

		if not found:
			self.logger.info(f'[Client] Unknown SNAC({hex(message.foodgroup)},{hex(message.subgroup)})')
			self.logger.info('[Client]', message.data.hex())

	def on_error_frame(self, data: bytes) -> None:
		self.logger.info('[Client] Recieved error frame with:', data.hex())

	def on_signoff_frame(self, data: bytes) -> None:
		self.logger.info('[Client] Recieved signoff frame')

	def close(self) -> None:
		if self.closed:
			return
		self.closed = True

		if self.close_callback:
			self.close_callback()

		if self.bs:
			self.bs.close()

	def _get_private_chat_with(self, other_user_uuid: str) -> Tuple[ChatSession, 'ChatEventHandler']:
		bs = self.bs
		assert bs is not None
		user = bs.user
		detail = user.detail
		assert detail is not None
		
		other_user = self.backend._load_user_record(other_user_uuid)
		if other_user is None:
			raise error.ContactNotOnContactList()
		other_user_ctc = detail.contacts.get(other_user.uuid)
		if other_user_ctc is not None and other_user_ctc.lists & ContactList.BL:
			raise error.ContactNotOnContactList()
		if other_user_uuid not in bs.front_data['oscar0_chats']:
			other_user_detail = self.backend._load_detail(other_user)
			if other_user_detail is None: raise error.ContactNotOnline()
			ctc_self = other_user_detail.contacts.get(user.uuid)
			if ctc_self is not None:
				if ctc_self.lists & ContactList.BL: raise error.ContactNotOnline()
			chat = self.backend.chat_create()
			chat.front_data['oscar0'] = True
			
			# `user` joins
			evt = ChatEventHandler(self.backend.loop, self, bs)
			cs = chat.join('icbm', bs, evt)
			bs.front_data['oscar0_chats'][other_user_uuid] = (cs, evt)
			cs.invite(other_user)
		elif other_user.status.is_offlineish():
			raise error.ContactNotOnline()
		return bs.front_data['oscar0_chats'].get(other_user_uuid)