#!/usr/bin/env python3
import asyncio
import datetime
import random
import re
import time

import isodate
import yaboli
from apiclient.discovery import build
from yaboli.util import asyncify, mention


class Video:
	DELAY = 4

	def __init__(self, vid, title, duration, blocked, allowed):
		self.id = vid
		self.title = title
		self.raw_duration = isodate.parse_duration(duration)
		self.duration = self.raw_duration + datetime.timedelta(seconds=self.DELAY)
		self.blocked = list(sorted(blocked)) if blocked is not None else None
		self.allowed = list(sorted(allowed)) if allowed is not None else None


class YouTube:
	def __init__(self, api_key):
		self.service = build("youtube", "v3", developerKey=api_key)

	async def get_videos(self, vids):
		vids = ",".join(vids)
		query = self.service.videos().list(part="id,contentDetails,snippet", id=vids)
		details = await asyncify(query.execute)

		videos = {}
		for info in details["items"]:
			vid = info["id"]
			title = info["snippet"]["title"]
			duration = info["contentDetails"]["duration"]
			blocked = info["contentDetails"].get("regionRestriction", {}).get("blocked", None)
			allowed = info["contentDetails"].get("regionRestriction", {}).get("allowed", None)

			video = Video(vid, title, duration, blocked, allowed)
			videos[vid] = video

		return videos


class Playlist:
	COUNTRIES = { # according to en.wikipedia.org/wiki/ISO_3166-1_alpha-2, 2018-08-17 18:12:15 UTC
		"AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU",
		"AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL",
		"BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC",
		"CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV",
		"CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG",
		"EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD",
		"GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT",
		"GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM",
		"IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH",
		"KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK",
		"LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH",
		"MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW",
		"MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR",
		"NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR",
		"PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC",
		"SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS",
		"ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL",
		"TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY",
		"UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA",
		"ZM", "ZW"
	}
	COMMON_COUNTRIES = {"DE", "FI", "FR", "GB", "IT", "JP", "NL", "PT", "US"}

	def __init__(self):
		self.waiting = []

		self.playing_task = None
		self.playing_video = None
		self.playing_until = None

	# formatting functions

	@staticmethod
	def format_duration(dt):
		seconds = int(dt.total_seconds())

		hours = seconds // (60*60)
		seconds -= hours * (60*60)

		minutes = seconds // 60
		seconds -= minutes * 60

		return f"{hours:02}:{minutes:02}:{seconds:02}"

	@staticmethod
	def format_list_entry(video, position=None, played_in=None):
		if position is None:
			position = "playing"

		info = f"[{position:2}] {video.title!r}"

		if played_in is not None:
			played_in = Playlist.format_duration(played_in)
			info = f"{info} will be played in [{played_in}]"

		#lines = [f"[{position:2}] {video.title!r} will be played in [{played_in}]"]
		lines = [info]

		blocked = None
		if video.blocked is not None:
			blocked = set(video.blocked)
			#lines.append(f"Blocked in {', '.join(video.blocked)}.")
		if video.allowed is not None:
			blocked = Playlist.COUNTRIES - set(video.allowed)
			#lines.append(f"Only viewable in {', '.join(video.allowed)}.")
		if blocked is not None:
			common = sorted(blocked & Playlist.COMMON_COUNTRIES)
			uncommon = sorted(blocked - Playlist.COMMON_COUNTRIES)

			if common:
				if uncommon:
					text = f"Blocked in {', '.join(common)} and {len(uncommon)} other "
					text += "country." if len(uncommon) == 1 else "countries."
				else:
					text = f"Blocked in {', '.join(common)}."
				lines.append(text)
			elif uncommon:
				if len(uncommon) <= 10:
					text = f"Blocked in {', '.join(uncommon)}."
				else:
					text = f"Blocked in {len(uncommon)} "
					text += "country." if len(uncommon) == 1 else "countries."
				lines.append(text)

		return lines

	@staticmethod
	def format_play(video, player):
		raw_duration = Playlist.format_duration(video.raw_duration)
		player = mention(player, ping=False)
		lines = [
			f"[{raw_duration}] {video.title!r} from {player}",
			f"!play youtube.com/watch?v={video.id}",
		]
		return "\n".join(lines)

	@staticmethod
	def format_next(video, player):
		if video and player:
			player = mention(player, ping=False)
			return f"Next: {video.title!r} from {player}"
		else:
			return "Next: Nothing"

	# commands regarding currently playing video

	def play(self, room):
		"""
		Start playing the first video in the queue unless something is already playing."

		Returns True if it started playing the first video of the queue,
		returns False otherwise (nothing happened).
		"""

		if self.waiting and not self.playing():
			self.playing_task = asyncio.ensure_future(self._play(room))
			#asyncio.ensure_future(self._play(room))
			return True
		else:
			return False

	def skip(self, room):
		if self.playing_task and not self.playing_task.done():
			self.playing_task.cancel()
			self.playing_task = None
		self.play(room)

	async def _play(self, room):
		"""
		Plays videos from the queue until it is empty.
		"""

		while self.waiting:
			video, player = self.waiting.pop(0)
			duration = video.duration.total_seconds()

			self.playing_video = video, player
			self.playing_until = time.time() + duration

			play_text = self.format_play(video, player)
			#msg = await room.send(play_text)

			next_video = self.next()
			video, player = next_video if next_video else (None, None)
			next_text = self.format_next(video, player)
			#await room.send(next_text, msg.mid)

			text = f"{play_text}\n{next_text}"
			await room.send(text)

			await asyncio.sleep(duration)

		self.playing_task = None
		self.playing_video = None
		self.playing_until = None

	# commands modifying the playlist

	def insert(self, video, player, before=None):
		element = (video, player)
		if before is None:
			position = len(self.waiting)
			self.waiting.append(element)
			return position
		elif before >= 0:
			self.waiting.insert(before, element)
			return min(before, len(self.waiting) - 1)
		else:
			return None

	def delete(self, position):
		if position < 0:
			return None

		try:
			return self.waiting.pop(position)
		except IndexError:
			return None

	def deleteall(self):
		self.waiting = []

	# playlist info

	def playing(self):
		return self.playing_task is not None and not self.playing_task.done()

	def empty(self):
		return not bool(self.waiting)

	def len(self):
		return len(self.waiting)

	def get(self, i):
		if i == -1 and self.playing():
			return self.playing_video

		try:
			return self.waiting[i]
		except IndexError:
			return None

	def items(self):
		return enumerate(self.waiting)

	def next(self):
		return self.waiting[0] if self.waiting else None

	def playtime_left(self):
		if self.playing_until:
			seconds = self.playing_until - time.time()
			return datetime.timedelta(seconds=seconds)
		else:
			return datetime.timedelta()

	def playtime_until(self, position=None):
		if position is None:
			videos = self.waiting
		else:
			videos = self.waiting[:position]

		video_sum = sum((video.duration for video, _ in videos), datetime.timedelta())
		return self.playtime_left() + video_sum


class ArgonDJBot(yaboli.Bot):
	HELP_GENERAL = "Keeps track of the video queue. !q <link> to queue a new video."
	HELP_SPECIFIC = [
		"Simply playing videos:",
		"!queue <urls or ids> - add videos to the queue (alias: !q)",
		"!skip - skip the currently playing video (alias: !s)",
		"",
		"Advanced queue manipulation:",
		"!list - display a list of currently queued videos (alias: !l)",
		"!detail <indices> - show more details for videos in the queue (aliases: !info, !show)",
		"\tTo reference the currently playing video, use '!detail playing'.",
		"!delete <index> - deletes video at that index in the queue (aliases: !del, !d)",
		"!insert before|after <index> <urls or ids> - insert videos in the queue (aliases: !ins, !i)",
		"!deleteall - remove the whole queue (aliases: !dall, !da, !flush)",
		"",
		"Fun stuff:",
		"!dramaticskip - dramatic version of !skip (aliases: !dskip, !ds)",
		"!videoskip - play a short video before the next queued video starts (aliases: !vskip, !vs)",
	]

	# Find the video id in a single argument
	VIDEO_ID_RE = r"[a-zA-Z0-9_-]{11}"
	YOUTUBE_RE = r"((https?://)?(www\.|music\.)?(youtube\.com/((watch|listen)\?(\S*&)?v=|embed/)|youtu\.be/))?(" + VIDEO_ID_RE + ")"
	YOUTUBE_RE_GROUP = 8

	DEL_RE = r"(\d+)" # Per argument
	INS_RE = r"(before|after)\s+(\d+)\s+(.*)" # On the whole argstr

	SKIP_VIDEOS = [
		"-6BlMb7IFFY", # Plop: Plunger to bald head
		"fClj2S6UzQA", # Ploop: Finger in metal cylinder
		"OfkViWKucCU", # Sold pupper dance
		"vGyHXW0lwZY", # Dog takes flight (maybe a bit loud?)
		"B6zk2Yd5ukc", # How to summon a cat on christmas
		"UJgwPRqVOoo", # Gecko party
		"7eysE77niUU", # I wanna be like you
		"ZH0lMFQifa4", # That's just neat
		"gdyp4Ez_T6I", # Wii soccer balls
		"CWGOt-Sic2s", # Running duck
		"6gxgfYKMUqE", # What happened
		"oa0arvrLZaM", # Dancing fish
		"Z7ioqD4ugh8", # parakeet
		"l1heD4T8Yco", # Dancing hamster
		"bLWGIYYEKfg", # urrg
		"4o5baMYWdtQ", # arf
		"y4aLXw7WwDM", # Muahahahaha
		"Ad9kJKCQ_kU", # Doggo's got talent
		"YxmdmJtUpjU", # Duck eating peas really fast
		"Ab_BdFr1BGg", # heard you were talkin shit (fat squirrel)
		"EixPRMs2jbY", # The microwave at work
		"9sxgbpTeiWQ", # Hedgehog
		"uqyHPs9D0z0", # Cat meaaaaaaaaaaaaaaaaaow
		"ua4nDQ-IGbY", # Cat vs. printing paper
		"rMMOQOLze4Y", # Polite rally driver
		"UBftA7V4xak", # Dog dancing to "Shake that ass for me"
		"F-X4SLhorvw", # Look at all those
		"qcdkbcjTBoE", # Seal yells
		"-w-58hQ9dLk", # Jurassic park (melodica cover)
		"rYfkmDoOnmM", # Dog says mama
		"P1iqLU2KWJk", # Have you ever had a dream
		"bQtmm_lpUKI", # World's most pathetic elevator chime
		"lGVoZuMbTI4", # Xenostapler
		"2p1DIiv5GWs", # A drum beatin'
		"aHAgeOx1cBM", # Kill bill blow dryer
		"UkakfkiydPw", # Bem bebebem bebem bebem bem bem
		"t1r6BmNJX6Y", # Dog treat scream
		"sMKoNBRZM1M", # Super mario ping pong
		#"DSi_FzQBNrU", # Markiplier do something spooky
		"KkDI4l2EldI", # SNAIL
		"NCu2saTv3QA", # Cat plays with foot
		"XOCuBpXi4zI", # Walking frog
		"Ul7IE3CTmSs", # annoyed news presenter
		"VV5JOQyUYNg", # Drum fill
		"PsLm6_qHeag", # Best dog in the world
		"_7jvYfIDbyU", # Not a rapper, an adapter
		"Vh8l0x9uF-Y", # Man sneezes into trombone
		"rkZ9sSgGPrs", # Ghostly mouse howl
		"Fl2pSbNvp_Q", # One hell of a yawn
		"LlFmBB8wzg0", # It's soup
		"ne-gcy--MeY", # Crow on webcam
		"1s04tEDJVjY", # Smooth criminal cat
		"P4JDgK6ib6Q", # Pigeon in river
		"vJqiq0Feqng", # Charles Cornell quack
	]
	DRAMATICSKIP_VIDEOS = [
		"VHkP88fx164", # animated video
		"0pTOXwYtSVk", # longer video
		"eVLOVpwXYGY", # dramatic chipmunk remix
		"IqTerZkJaCU", # dramatic chipmunk vs shocked squirrel
		"G4BuQ_0oU0I", # 8-bit chipmunk
		"Wt0GiBkyCC0", # dramatic cat
	] + ["y8Kyi0WNg40"]*100 # original video

	def __init__(self, *args, **kwargs):
		super().__init__(*args, **kwargs)

		self.register_botrulez(kill=True, restart=True)

		self.register_general("queue", self.command_queue)
		self.register_general("q", self.command_queue)

		self.register_general("skip", self.command_skip, args=False)
		self.register_general("s", self.command_skip, args=False)

		self.register_general("list", self.command_list, args=False)
		self.register_general("l", self.command_list, args=False)

		self.register_general("detail", self.command_detail)
		self.register_general("info", self.command_detail)
		self.register_general("show", self.command_detail)

		self.register_general("delete", self.command_delete)
		self.register_general("del", self.command_delete)
		self.register_general("d", self.command_delete)

		self.register_general("insert", self.command_insert)
		self.register_general("ins", self.command_insert)
		self.register_general("i", self.command_insert)

		self.register_general("deleteall", self.command_deleteall, args=False)
		self.register_general("dall", self.command_deleteall, args=False)
		self.register_general("da", self.command_deleteall, args=False)
		self.register_general("flush", self.command_deleteall, args=False)

		self.register_general("dramaticskip", self.command_dskip, args=False)
		self.register_general("dskip", self.command_dskip, args=False)
		self.register_general("ds", self.command_dskip, args=False)

		self.register_general("videoskip", self.command_vskip, args=False)
		self.register_general("vskip", self.command_vskip, args=False)
		self.register_general("vs", self.command_vskip, args=False)

		self.yt = YouTube(self.config.get("argon", "api_key"))
		self.playlist = Playlist()

	async def find_videos(self, args):
		video_ids = []
		lines_parse_error = []
		for arg in args:
			if arg == "-id": continue
			match = re.match(self.YOUTUBE_RE, arg)
			if match:
				video_ids.append(match.group(self.YOUTUBE_RE_GROUP))
			else:
				lines_parse_error.append(f"Could not parse {arg!r}")

		videos = []
		lines_api_error = []
		video_lookup = await self.yt.get_videos(video_ids)
		for vid in video_ids:
			video = video_lookup.get(vid)
			if video:
				videos.append(video)
			else:
				lines_api_error.append(f"Video with id {vid} could not be accessed via the API")

		return videos, lines_parse_error, lines_api_error

	async def command_queue(self, room, msg, args):
		videos, lines_parse_error, lines_api_error = await self.find_videos(args.basic())

		if not videos:
			text = "\n".join(lines_parse_error + lines_api_error)
			await msg.reply("ERROR: No valid videos specified\n" + text)
			return

		in_playlist = []
		for video in videos:
			position = self.playlist.insert(video, msg.sender.nick)
			until = self.playlist.playtime_until(position)
			in_playlist.append((video, position, until))

		lines = []

		playing = self.playlist.play(room)
		if playing:
			video, _, _ = in_playlist[0]
			info = Playlist.format_list_entry(video)
			lines.extend(info)

			in_playlist = [(v, p-1, u) for v, p, u in in_playlist[1:]]

		for video, position, until in in_playlist:
			info = Playlist.format_list_entry(video, position, until)
			lines.extend(info)

		text = "\n".join(lines + lines_parse_error + lines_api_error)
		await msg.reply(text)

	async def command_skip(self, room, msg, args):
		if self.playlist.empty():
			vid = random.choice(self.SKIP_VIDEOS)
			videos = await self.yt.get_videos([vid])
			video = videos.get(vid)
			self.playlist.insert(video, room.session.nick, before=0)

		await msg.reply("Skipping to next video")
		self.playlist.skip(room)

	async def command_vskip(self, room, msg, args):
		vid = random.choice(self.SKIP_VIDEOS)
		videos = await self.yt.get_videos([vid])
		video = videos.get(vid)
		self.playlist.insert(video, room.session.nick, before=0)

		await msg.reply("Skipping to next video")
		self.playlist.skip(room)

	async def command_dskip(self, room, msg, args):
		vid = random.choice(self.DRAMATICSKIP_VIDEOS)
		videos = await self.yt.get_videos([vid])
		video = videos.get(vid)
		self.playlist.insert(video, room.session.nick, before=0)

		await msg.reply("Skipping to next video")
		self.playlist.skip(room)

	async def command_list(self, room, msg, args):
		lines = []

		if self.playlist.playing():
			(video, _) = self.playlist.playing_video
			info = Playlist.format_list_entry(video)
			lines.extend(info)

		for position, (video, _) in self.playlist.items():
			until = self.playlist.playtime_until(position)
			info = Playlist.format_list_entry(video, position, until)
			lines.extend(info)

		if lines:
			text = "\n".join(lines)
		else:
			text = "Queue is empty"

		await msg.reply(text)

	async def command_detail(self, room, msg, args):
		indices = []
		lines_parse_error = []
		for arg in args.basic():
			match = re.match(r"\d+", arg)
			if match:
				indices.append(int(match.group(0)))
			elif arg == "playing":
				indices.append(-1)
			else:
				lines_parse_error.append(f"Could not parse {arg!r}")

		videos = []
		lines_index_error = []
		for i in sorted(set(indices)):
			video = self.playlist.get(i)
			if video:
				v, p = video
				videos.append((i, v, p))
			else:
				lines_index_error.append(f"No video at index {i}")

		if not videos:
			text = "\n".join(["ERROR: No valid indices given"] + lines_parse_error + lines_index_error)
			await msg.reply(text)
			return

		lines = []
		for index, video, player in videos:
			index = "playing" if index == -1 else index
			info = []
			info.append(f"[{index:2}] youtube.com/watch?v={video.id} {video.title!r}")
			info.append(f"Queued by {mention(player, ping=False)}")

			if video.blocked is not None:
				info.append(f"Blocked in {', '.join(video.blocked)}.")
			if video.allowed is not None:
				info.append(f"Only viewable in {', '.join(video.allowed)}.")

			lines.extend(info)
			lines.append("")

		text = "\n".join(lines + lines_parse_error + lines_index_error)
		await msg.reply(text)

	async def command_delete(self, room, msg, args):
		indices = []
		lines_parse_error = []
		for arg in args.basic():
			match = re.fullmatch(self.DEL_RE, arg)
			if match:
				indices.append(int(match.group(1)))
			else:
				lines_parse_error.append(f"Could not parse {arg!r}")

		if not indices:
			text = "\n".join(["ERROR: No valid indices given"] + lines_parse_error)
			await msg.reply(text)
			return

		lines = []
		lines_remove_error = []
		for i in reversed(sorted(set(indices))):
			success = self.playlist.delete(i)
			if success:
				video, _ = success
				lines.append(f"Removed {video.title!r}")
			else:
				lines_remove_error.append(f"No video at index {i}")

		text = "\n".join(lines + lines_parse_error + lines_remove_error)
		await msg.reply(text)

	async def command_insert(self, room, msg, args):
		match = re.fullmatch(self.INS_RE, args.raw)
		if not match:
			await msg.reply("ERROR: Invalid command syntax")
			return

		mode = match.group(1)
		before = int(match.group(2))
		args = self.parse_args(match.group(3))

		videos, lines_parse_error, lines_api_error = await self.find_videos(args)

		if not videos:
			text = "\n".join(lines_parse_error + lines_api_error)
			await msg.reply("ERROR: No valid videos specified\n" + text)
			return

		if mode == "after":
			before += 1

		lines = []
		for video in videos:
			position = self.playlist.insert(video, msg.sender.nick, before=before)
			before += 1
			until = self.playlist.playtime_until(position)

			info = Playlist.format_list_entry(video, position, until)
			lines.extend(info)

		text = "\n".join(lines + lines_parse_error + lines_api_error)
		await msg.reply(text)
		self.playlist.play(room)

	async def command_deleteall(self, room, msg, args):
		self.playlist.deleteall()
		await msg.reply("Queue deleted")


def main():
	yaboli.run(ArgonDJBot)

if __name__ == "__main__":
	main()
