import org.serviio.library.online.*
import groovy.json.*
import java.util.regex.Matcher
import java.text.SimpleDateFormat

class NHLPlugin extends WebResourceUrlExtractor
{
	static final String USERNAME = 'YOUR_USERNAME' // username or email
	static final String PASSWORD = 'YOUR_PASSWORD'
	static final String FAV_TEAM = 'WSH' // for full games

	boolean extractorMatches(URL resourceUrl) {
		return resourceUrl ==~ /http[s]?:\/\/www\.nhl\.com\/tv(\/.*)?/
	}

	String getExtractorName() {
		return 'NHL.TV'
	}

	int getVersion() {
		return 1
	}

	int getExtractItemsTimeout() {
		return 60
	}

	WebResourceContainer extractItems(URL resourceUrl, int maxItemsToRetrieve) {
		logi('extracting items..')

		def iterator = liveThreads.entrySet().iterator()
		while (iterator.hasNext()) {
			if (iterator.next().value.thread == null) {
				iterator.remove()
			}
		}

		def isoDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")
		isoDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"))

		def items = new ArrayList<WebResourceItem>()

		for (int daysBefore = 0; daysBefore < 3; daysBefore++) {
			def cal = new GregorianCalendar(TimeZone.getTimeZone('America/New_York')) // ET
			cal.add(Calendar.DAY_OF_MONTH, -daysBefore)
			def dateStr = String.format('%4d-%02d-%02d', cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH))

			def schedule = null
			new URL('https://statsapi.web.nhl.com/api/v1/schedule?date=' +
					dateStr +
					'&expand=schedule.game.content.media.epg,schedule.teams'
			).openConnection().with {
				setRequestProperty('X-Platform', 'xbox-one') // Extended Highlights for xbox available earlier than for 'web' platform

				schedule = new JsonSlurper().parseText(content.text as String)
			}

			List<Map> games = schedule.dates.find { it.date == dateStr }.games

			Collections.sort(games, new Comparator<Map>() {
				int compare(Map game1, Map game2) {
					Date game1Date = isoDateFormat.parse(game1.gameDate as String);
					Date game2Date = isoDateFormat.parse(game2.gameDate as String);
					return game2Date.compareTo(game1Date);
				}
			});

			games.each { game ->
				// 1: Scheduled
				// 2: Pre-Game
				// 3: In Progress
				// 4: In Progress - Critical
				// 6: Final
				// 7: Final

				if (game.status.codedGameState != '1') {
					boolean isFav = game.teams.away.team.abbreviation == FAV_TEAM ||
									game.teams.home.team.abbreviation == FAV_TEAM

					String videoUrl = null, videoType = '?', mediaAuth_v2 = null
					boolean live = false

					def highlightsUrl = null
					def epg = game.content.media.epg
					def item = epg.find { it.title == 'Extended Highlights' }
					if (!item.items) {
						item = epg.find { it.title == 'Recap' }
					}
					item = item.items.find { it.type == 'video' }
					if (item) {
						highlightsUrl = item.playbacks.HTTP_CLOUD_WIRED_60
						if (!highlightsUrl)
							highlightsUrl = item.playbacks.find { it.name == 'HTTP_CLOUD_WIRED_60' }.url
					}

					if (isFav || !highlightsUrl) {
						item = epg.find { it.title == 'NHLTV' }

						def feedTypes
						if (game.teams.away.team.abbreviation == FAV_TEAM)
							feedTypes = ['AWAY', 'NATIONAL', 'HOME']
						else
							feedTypes = ['HOME', 'NATIONAL', 'AWAY']

						for (feedType in feedTypes) {
							def it = item.items.find { it.mediaFeedType == feedType && (it.mediaState == 'MEDIA_ARCHIVE' || it.mediaState == 'MEDIA_ON') } // but not 'MEDIA_DONE'
							if (it) {
								item = it
								break
							}
						}

						if (item.mediaPlaybackId) {
							if (item.mediaState == 'MEDIA_ON') {
								videoType = 'Live'
								live = true
							} else if (item.mediaState == 'MEDIA_ARCHIVE') {
								videoType = 'Full'
							}

							new URL('https://mf.svc.nhl.com/ws/media/mf/v2.4/stream?contentId=' +
									item.mediaPlaybackId.toString() +
									'&playbackScenario=HTTP_CLOUD_WIRED_60' +
									((sessionKey != null) ? '&sessionKey=' + URLEncoder.encode(sessionKey, "UTF-8") : '') +
									'&auth=response&format=json'
							).openConnection().with {
								setRequestProperty('Authorization', getAuthCookie())

								def stream = new JsonSlurper().parseText(content.text as String)

								if (stream.status_code == 1) {
									sessionKey = stream.session_key

									Object[] user_verified_event = stream.user_verified_event
									Object[] user_verified_content = user_verified_event[0].user_verified_content
									Object[] user_verified_media_item = user_verified_content[0].user_verified_media_item
									videoUrl = user_verified_media_item[0].url

									mediaAuth_v2 = stream.session_info.sessionAttributes.find { it.attributeName == 'mediaAuth_v2' }.attributeValue
								} else {
									logw('failed to get full video URL for game ' + game.gamePk.toString() + ': ' + stream.toString())
								}
							}
						} else {
							logw('feed not found for game ' + game.gamePk.toString())
						}
					} else {
						videoUrl = highlightsUrl
						videoType = 'HiLi'
					}

					if (videoUrl) {
						def gameDate = isoDateFormat.parse(game.gameDate as String)

						def title = String.format(
								"%s%s @ %s (%s) %s",
								isFav ? '* ' : '',
								game.teams.away.team.abbreviation,
								game.teams.home.team.abbreviation,
								videoType,
								new SimpleDateFormat("EEE MMM d H:mm", Locale.US).format(gameDate))

						items.add(new WebResourceItem(
								title: title,
								releaseDate: gameDate,
								additionalInfo: ['URL': videoUrl, 'mediaAuth_v2': mediaAuth_v2, 'live': live.toString()],
								cacheKey: videoUrl))
					}
				}
			}
		}

		return new WebResourceContainer(
				items: items)
	}
	
	ContentURLContainer extractUrl(WebResourceItem item, PreferredQuality requestedQuality) {
		logi('extracting URL for "' + item.getTitle() + '" with ' + requestedQuality + ' quality')

		boolean live = item.getAdditionalInfo()['live'].toBoolean()

		String videoUrl = item.getAdditionalInfo()['URL']
		logi(videoUrl)

		String m3u8 = new URL(videoUrl).openConnection().content.text
		String lastModified = null

		def streams = []
		(m3u8 =~ /#EXT-X-STREAM-INF:(.*BANDWIDTH=(\d+).*)\r?\n(.*)/).each {
			streams.add([inf: it[1], bw: it[2], ref: it[3]])
		}

		streams.sort {
			a, b -> a.bw.toInteger() - b.bw.toInteger()
		}

		// HIGH - best available quality, MEDIUM - second best, LOW - third
		def pref_idx = Math.min(Math.max(streams.size() - 3 + PreferredQuality.values().findIndexOf { it == requestedQuality }, 0), streams.size() - 1)

		for (int idx = pref_idx; idx >= 0; idx--) {
			def testUrl = videoUrl.substring(0, videoUrl.lastIndexOf('/') + 1) + (streams[idx].ref as String)
			new URL(testUrl).openConnection().with {
				connect()

				if (getResponseCode() == 200) {
					videoUrl = testUrl
					m3u8 = content.text
					lastModified = getHeaderField('Last-Modified')
					logi(streams[idx].inf as String)
				} else {
					logw('invalid URL: ' + testUrl)
				}
			}
			if (videoUrl == testUrl)
				break
		}

		String webVideoUrl = videoUrl
		boolean expiresImmediately = false

		def keys = [:]
		(m3u8 =~ /#EXT-X-KEY:(?:.*,)?URI="(.+?)".*/).each {
			if (!keys.containsKey(it[1]))
				keys.put(it[1], null)
		}

		if (keys) {
			def baseUrlPath = videoUrl.substring(0, videoUrl.lastIndexOf('/') + 1)

			if (!(live && !(m3u8  ==~ /(?s).*#EXT-X-ENDLIST\s*/)) ||
					!liveThreads.containsKey(webVideoUrl)) {
				String m3u8_subst = m3u8

				keys.each { entry ->
					logi('downloading key: ' + entry.key)

					new URL(entry.key as String).openConnection().with {
						setRequestProperty('Cookie', String.format('mediaAuth_v2=%s; Authorization=%s;', item.getAdditionalInfo()['mediaAuth_v2'], getAuthCookie()))

						connect()

						File.createTempFile("nhl", ".key").with { file ->
							new FileOutputStream(file).write(inputStream.getBytes())
							m3u8_subst = m3u8_subst.replace('"' + entry.key + '"', '"' + name + '"')
							entry.value = name
							deleteOnExit()
						}
					}
				}

				m3u8_subst = m3u8_subst.replaceAll(/(#EXTINF:.*\r?\n)(.*)/, '$1' + Matcher.quoteReplacement(baseUrlPath) + '$2')

				File.createTempFile("nhl", ".m3u8").with {
					write(m3u8_subst)
					logi('substitute m3u8 file: ' + absolutePath)
					videoUrl = absolutePath.replace(separator, '/')
					deleteOnExit()
				}
			}

			if (live && !(m3u8  ==~ /(?s).*#EXT-X-ENDLIST\s*/)) {
				if (!liveThreads.containsKey(webVideoUrl)) {
					expiresImmediately = true
					liveThreads.put(webVideoUrl, [videoUrl: videoUrl, keys: keys, thread: null])
				} else {
					videoUrl = liveThreads[webVideoUrl].videoUrl
					keys = liveThreads[webVideoUrl].keys
					if (liveThreads[webVideoUrl].thread == null) {
						liveThreads[webVideoUrl].thread = new Thread({
							logi('started downloading live stream updates for ' + item.title)

							boolean abort = false
							int noUpdatesCounter = 0

							while (!abort) {
								sleep(4000)

								new URL(webVideoUrl).openConnection().with {
									setRequestProperty('If-Modified-Since', lastModified)
									setRequestProperty('Range', String.format('bytes=%d-', m3u8.length()))

									connect()

									if (getResponseCode() == 304 || getResponseCode() == 416) {
										// Not Modified || Requested Range Not Satisfiable
										noUpdatesCounter++
										if (noUpdatesCounter >= 100) {
											logw('m3u8 was not updated for long time for ' + item.title)
											abort = true
										}
									} else if (getResponseCode() == 206) {
										noUpdatesCounter = 0
										String appended_m3u8 = content.text
										lastModified = getHeaderField('Last-Modified')

										def appended_m3u8_subst = appended_m3u8

										(appended_m3u8 =~ /#EXT-X-KEY:(?:.*,)?URI="(.+?)".*/).each {
											def keyUrl = it[1]
											if (!keys.containsKey(keyUrl)) {
												logi('downloading key: ' + keyUrl)

												new URL(keyUrl).openConnection().with {
													setRequestProperty('Cookie', String.format('mediaAuth_v2=%s; Authorization=%s;', item.getAdditionalInfo()['mediaAuth_v2'], getAuthCookie()))

													connect()

													File.createTempFile("nhl", ".key").with { file ->
														new FileOutputStream(file).write(inputStream.getBytes())
														keys[keyUrl] = name
														deleteOnExit()
													}
												}
											}
											appended_m3u8_subst = appended_m3u8_subst.replace('"' + keyUrl + '"', '"' + keys[keyUrl] + '"')
										}

										appended_m3u8_subst = appended_m3u8_subst.replaceAll(/(#EXTINF:.*\r?\n)(.*)/, '$1' + Matcher.quoteReplacement(baseUrlPath) + '$2')

										new File(videoUrl).withWriterAppend {
											it << appended_m3u8_subst
										}

										if (appended_m3u8  ==~ /(?s).*#EXT-X-ENDLIST\s*/) {
											logi('live stream completed for ' + item.title)
											abort = true
										}

										m3u8 += appended_m3u8
									} else {
										logw('server returned status code ' + getResponseCode().toString() + ' when loading live stream updates for ' + item.title)
										abort = true
									}
								}
							}
						})
						liveThreads[webVideoUrl].thread.setDaemon(true)
						liveThreads[webVideoUrl].thread.start()
					}
				}
			}
		}

		def calExpire = Calendar.getInstance()
		if (live)
			calExpire.add(Calendar.HOUR_OF_DAY, 6)
		else
			calExpire.add(Calendar.DAY_OF_MONTH, 7)

		return new ContentURLContainer(
				contentUrl: videoUrl,
				live: live,
				expiresOn: calExpire.getTime(),
				expiresImmediately: expiresImmediately,
				cacheKey: webVideoUrl)
	}

	def liveThreads = [:]

	String authCookie = null;
	Date authCookieExpires;
	String authCookieFilePath = null;
	String sessionKey = null;

	String getAuthCookie() {
		if (!authCookieFilePath) {
			File cookieFile = new File(System.getProperty('java.io.tmpdir'), 'nhl_plugin.cookie')
			if (cookieFile.exists()) {
				def matcher = (cookieFile.text =~ /Authorization=([^;]+).*Expires=([^;]+)/)
				if (matcher) {
					String[] groups = matcher[0]
					authCookie = groups[1]
					authCookieExpires = new SimpleDateFormat('EEE, dd-MMM-yyyy HH:mm:ss zzz', Locale.ROOT).parse(groups[2])
					logi('auth cookie read from file, expires: ' + authCookieExpires.toString())
				}
			}
			authCookieFilePath = cookieFile.absolutePath
		}

		if (authCookie == null || authCookieExpires < new Date()) {
			logi('performing login to NHL server..')
			authCookie = null

			new URL('https://gateway.web.nhl.com/ws/subscription/flow/nhlPurchase.login').openConnection().with {
				requestMethod = 'POST'
				setRequestProperty('Content-Type', 'application/json')
				doOutput = true

				outputStream.withWriter { writer ->
					writer << JsonOutput.toJson([nhlCredentials: [email: USERNAME, password: PASSWORD]])
				}

				connect()

				for (cookie in getHeaderFields()['Set-Cookie']) {
					def matcher = (cookie =~ /Authorization=([^;]+).*Expires=([^;]+)/)
					if (matcher) {
						String[] groups = matcher[0]
						authCookie = groups[1]
						authCookieExpires = new SimpleDateFormat('EEE, dd-MMM-yyyy HH:mm:ss zzz', Locale.ROOT).parse(groups[2])
						logi('auth cookie received, expires: ' + authCookieExpires.toString())

						new File(authCookieFilePath).withWriter {
							it.write(cookie)
						}
					}
				}

				if (!authCookie)
					logw('auth cookie not found in server response')
			}
		}

		return authCookie
	}

	void logi(String msg) {
		log.info(this.class.name + ': ' + msg)
	}

	void logw(String msg) {
		log.warn(this.class.name + ': ' + msg)
	}

	static main(args) {
		def plugin = new NHLPlugin()
		println plugin.extractorMatches(new URL('https://www.nhl.com/tv'))
		println plugin.getExtractorName()
		println plugin.getVersion()
		plugin.extractItems(new URL('https://www.nhl.com/tv'), -1).getItems().each {
			println(it)
			println plugin.extractUrl(it, PreferredQuality.HIGH)
		}
	}
}
