<!--
  - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
  - SPDX-License-Identifier: AGPL-3.0-or-later
-->

<template>
	<NcContent
		:class="{ 'icon-loading': loading, 'in-call': isInCall }"
		appName="talk">
		<LeftSidebar v-if="getUserId" ref="leftSidebar" />
		<NcAppContent>
			<router-view />
		</NcAppContent>
		<RightSidebar :isInCall="isInCall" />
		<MediaSettings v-model:recordingConsentGiven="recordingConsentGiven" />
		<SettingsDialog />
		<ConversationSettingsDialog />
		<PollManager />
	</NcContent>
</template>

<script>
import { getCurrentUser } from '@nextcloud/auth'
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile'
import { spawnDialog } from '@nextcloud/vue/functions/dialog'
import debounce from 'debounce'
import { provide } from 'vue'
import { START_LOCATION } from 'vue-router'
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
import NcContent from '@nextcloud/vue/components/NcContent'
import ConversationSettingsDialog from './components/ConversationSettings/ConversationSettingsDialog.vue'
import LeftSidebar from './components/LeftSidebar/LeftSidebar.vue'
import MediaSettings from './components/MediaSettings/MediaSettings.vue'
import PollManager from './components/PollViewer/PollManager.vue'
import RightSidebar from './components/RightSidebar/RightSidebar.vue'
import SettingsDialog from './components/SettingsDialog/SettingsDialog.vue'
import ConfirmDialog from './components/UIShared/ConfirmDialog.vue'
import { useActiveSession } from './composables/useActiveSession.js'
import {
	toggleFullscreen,
	useDocumentFullscreen,
} from './composables/useDocumentFullscreen.ts'
import { useDocumentTitle } from './composables/useDocumentTitle.ts'
import { useGetMessagesProvider } from './composables/useGetMessages.ts'
import { useGetToken } from './composables/useGetToken.ts'
import { useHashCheck } from './composables/useHashCheck.js'
import { useInterceptNotifications } from './composables/useInterceptNotifications.ts'
import { useIsInCall } from './composables/useIsInCall.js'
import { useSessionIssueHandler } from './composables/useSessionIssueHandler.ts'
import { CONVERSATION, PARTICIPANT } from './constants.ts'
import BrowserStorage from './services/BrowserStorage.js'
import { EventBus } from './services/EventBus.ts'
import { leaveConversationSync } from './services/participantsService.js'
import { useActorStore } from './stores/actor.ts'
import { useCallViewStore } from './stores/callView.ts'
import { useSidebarStore } from './stores/sidebar.ts'
import { useTokenStore } from './stores/token.ts'
import { checkBrowser } from './utils/browserCheck.ts'
import { signalingKill } from './utils/webrtc/index.js'

export default {
	name: 'App',
	components: {
		NcAppContent,
		NcContent,
		LeftSidebar,
		RightSidebar,
		SettingsDialog,
		ConversationSettingsDialog,
		MediaSettings,
		PollManager,
	},

	setup() {
		useDocumentTitle()
		// Provide context for MessagesList mounted in different places
		useGetMessagesProvider()
		// Intercept some notifications and handle them in the Talk app
		useInterceptNotifications()
		// Add provided value to check if we're in the main app or plugin
		provide('Talk:isMainApp', true)
		useDocumentFullscreen()

		return {
			token: useGetToken(),
			tokenStore: useTokenStore(),
			isInCall: useIsInCall(),
			isLeavingAfterSessionIssue: useSessionIssueHandler(),
			isMobile: useIsMobile(),
			isNextcloudTalkHashDirty: useHashCheck(),
			supportSessionState: useActiveSession(),
			callViewStore: useCallViewStore(),
			sidebarStore: useSidebarStore(),
			actorStore: useActorStore(),
		}
	},

	data() {
		return {
			loading: false,
			isRefreshingCurrentConversation: false,
			skipLeaveWarning: false,
			recordingConsentGiven: false,
			debounceRefreshCurrentConversation: () => {},
		}
	},

	computed: {
		unreadCountsMap() {
			return this.$store.getters.conversationsList.reduce((acc, conversation) => {
				if (conversation.isArchived) {
					// Do not consider archived conversations in counting
					return acc
				}

				if (conversation.unreadMessages > 0) {
					acc.conversations++
					acc.messages += conversation.unreadMessages
				}

				if (conversation.unreadMention) {
					acc.mentions++
				}

				if (conversation.unreadMentionDirect) {
					acc.mentionsDirect++
				}
				return acc
			}, {
				conversations: 0,
				messages: 0,
				mentions: 0,
				mentionsDirect: 0,
			})
		},

		getUserId() {
			return this.actorStore.userId
		},

		isSendingMessages() {
			return this.$store.getters.isSendingMessages
		},

		warnLeaving() {
			return !this.isLeavingAfterSessionIssue && this.isInCall
		},

		/**
		 * The current conversation
		 *
		 * @return {object} The conversation object.
		 */
		currentConversation() {
			return this.$store.getters.conversation(this.token)
		},
	},

	watch: {
		token(newValue, oldValue) {
			const shouldShowSidebar = BrowserStorage.getItem('sidebarOpen') !== 'false'
			if (!shouldShowSidebar || this.isMobile) {
				this.sidebarStore.hideSidebar({ cache: false })
			} else if (shouldShowSidebar) {
				this.sidebarStore.showSidebar({ cache: false })
			}

			// Reset recording consent if switch doesn't happen within breakout rooms or main room
			if (!this.isBreakoutRoomsNavigation(oldValue, newValue)) {
				this.recordingConsentGiven = false
			}
		},

		isInCall: {
			immediate: true,
			handler(value) {
				const toggle = this.$refs.leftSidebar?.$refs.leftSidebar?.$el.querySelector('button.app-navigation-toggle')
				if (value) {
					toggle?.setAttribute('data-theme-dark', true)
				} else {
					toggle?.removeAttribute('data-theme-dark')
				}
			},
		},

		unreadCountsMap: {
			deep: true,
			immediate: true,
			handler(value) {
				emit('talk:unread:updated', value)
			},
		},
	},

	beforeCreate() {
		const authorizedUser = getCurrentUser()?.uid || null
		const lastLoggedInUser = BrowserStorage.getItem('last_logged_in_user')

		if (authorizedUser !== lastLoggedInUser) {
			// TODO introduce helper/util to list and clear all sensitive data
			// or create BrowserSensitiveStorage for this purposes,
			// if we have more than one source
			BrowserStorage.removeItem('cachedConversations')
		}

		if (authorizedUser) {
			BrowserStorage.setItem('last_logged_in_user', authorizedUser)
		}
	},

	created() {
		window.addEventListener('beforeunload', this.preventUnload)
		useHotKey('f', this.handleAppSearch, { ctrl: true, stop: true, prevent: true })
		useHotKey('f', toggleFullscreen)
		if (getCurrentUser()) {
			useHotKey('Escape', this.openRoot, { stop: true, prevent: true })
		}
	},

	beforeUnmount() {
		this.debounceRefreshCurrentConversation.clear?.()
		if (!getCurrentUser()) {
			EventBus.off('should-refresh-conversations', this.debounceRefreshCurrentConversation)
		}

		window.removeEventListener('beforeunload', this.preventUnload)

		EventBus.off('joined-conversation')
		EventBus.off('switch-to-conversation')
		EventBus.off('conversations-received')
		EventBus.off('forbidden-route')
	},

	beforeMount() {
		if (!getCurrentUser()) {
			/**
			 * When guest opens a public conversation, we wait for it to be fetched,
			 * then setting the 30 seconds interval to update information.
			 * Joining is handled by router (initial navigation to 'conversation')
			 */
			EventBus.once('conversations-received', (params) => {
				setInterval(() => {
					this.refreshCurrentConversation()
				}, 30_000)
			})
			EventBus.on('should-refresh-conversations', this.debounceRefreshCurrentConversation)
		}

		window.addEventListener('unload', () => {
			console.info('Navigating away, leaving conversation')
			if (this.token) {
				SessionStorage.removeItem('joined_conversation')
				// We have to do this synchronously, because in unload and beforeunload
				// Promises, async and await are prohibited.
				signalingKill()
				if (!this.isLeavingAfterSessionIssue) {
					leaveConversationSync(this.token)
				}
			}
		})

		EventBus.on('switch-to-conversation', async (params) => {
			if (this.isInCall) {
				this.callViewStore.setForceCallView(true)

				const enableAudio = !BrowserStorage.getItem('audioDisabled_' + this.token)
				const enableVideo = !BrowserStorage.getItem('videoDisabled_' + this.token)
				const enableVirtualBackground = !!BrowserStorage.getItem('virtualBackgroundEnabled_' + this.token)
				const virtualBackgroundType = BrowserStorage.getItem('virtualBackgroundType_' + this.token)
				const virtualBackgroundBlurStrength = BrowserStorage.getItem('virtualBackgroundBlurStrength_' + this.token)
				const virtualBackgroundUrl = BrowserStorage.getItem('virtualBackgroundUrl_' + this.token)

				// Fetch conversation object, if it's not known yet to the client
				if (!this.$store.getters.conversation(params.token)) {
					await this.fetchSingleConversation(params.token)
				}

				const conversation = this.$store.getters.conversation(this.token)
				const previousParticipants = []
				if (conversation.type === CONVERSATION.TYPE.ONE_TO_ONE) {
					previousParticipants.push(conversation.name)
				}

				EventBus.once('joined-conversation', async ({ token }) => {
					if (params.token !== token) {
						return
					}

					if (enableAudio) {
						BrowserStorage.removeItem('audioDisabled_' + token)
					} else {
						BrowserStorage.setItem('audioDisabled_' + token, 'true')
					}
					if (enableVideo) {
						BrowserStorage.removeItem('videoDisabled_' + token)
					} else {
						BrowserStorage.setItem('videoDisabled_' + token, 'true')
					}
					if (enableVirtualBackground) {
						BrowserStorage.setItem('virtualBackgroundEnabled_' + token, 'true')
					} else {
						BrowserStorage.removeItem('virtualBackgroundEnabled_' + token)
					}
					if (virtualBackgroundType) {
						BrowserStorage.setItem('virtualBackgroundType_' + token, virtualBackgroundType)
					} else {
						BrowserStorage.removeItem('virtualBackgroundType_' + token)
					}
					if (virtualBackgroundBlurStrength) {
						BrowserStorage.setItem('virtualBackgroundBlurStrength' + token, virtualBackgroundBlurStrength)
					} else {
						BrowserStorage.removeItem('virtualBackgroundBlurStrength' + token)
					}
					if (virtualBackgroundUrl) {
						BrowserStorage.setItem('virtualBackgroundUrl_' + token, virtualBackgroundUrl)
					} else {
						BrowserStorage.removeItem('virtualBackgroundUrl_' + token)
					}

					const conversation = this.$store.getters.conversation(token)

					let flags = PARTICIPANT.CALL_FLAG.IN_CALL
					if (conversation.permissions & PARTICIPANT.PERMISSIONS.PUBLISH_AUDIO) {
						flags |= PARTICIPANT.CALL_FLAG.WITH_AUDIO
					}
					if (conversation.permissions & PARTICIPANT.PERMISSIONS.PUBLISH_VIDEO) {
						flags |= PARTICIPANT.CALL_FLAG.WITH_VIDEO
					}

					const payload = {
						token: params.token,
						participantIdentifier: this.actorStore.participantIdentifier,
						flags,
						silent: true,
						recordingConsent: this.recordingConsentGiven,
					}

					if (conversation.objectType === CONVERSATION.OBJECT_TYPE.EXTENDED) {
						payload.silent = false
						if (previousParticipants.length) {
							payload.silentFor = previousParticipants
						}
					}

					await this.$store.dispatch('joinCall', payload)

					this.callViewStore.setForceCallView(false)
				})
			}

			this.skipLeaveWarning = true
			this.$router.push({ name: 'conversation', params: { token: params.token } })
		})

		EventBus.on('conversations-received', (params) => {
			if (this.$route === START_LOCATION) {
				// Initial navigation, should be handled in beforeRouteChangeListener
				return
			}
			if (this.$route.name === 'conversation'
				&& !this.$store.getters.conversation(this.token)) {
				if (!params.singleConversation) {
					console.info('Conversations received, but the current conversation is not in the list, trying to get potential public conversation manually')
					this.refreshCurrentConversation()
				} else {
					console.info('Conversation received, but the current conversation is not in the list. Redirecting to /apps/spreed/not-found')
					this.skipLeaveWarning = true
					this.$router.push({ name: 'notfound' })
				}
			}
		})

		EventBus.on('forbidden-route', (params) => {
			this.$router.push({ name: 'forbidden' })
		})

		const beforeRouteChangeListener = async (to, from, next) => {
			if (this.isNextcloudTalkHashDirty) {
				// Nextcloud Talk configuration changed, reload the page when changing configuration
				window.location = generateUrl('call/' + to.params.token)
				return
			}

			if (from.name === 'conversation' && from.params.token !== to.params.token) {
				// Await to properly close session / leave call before joining another one
				await this.$store.dispatch('leaveConversation', { token: from.params.token })
			}

			/**
			 * This runs whenever the new route is a conversation.
			 */
			if (to.name === 'conversation' && from.params.token !== to.params.token) {
				// Fetch conversation object, if it's not known yet to the client
				if (!this.$store.getters.conversation(to.params.token)) {
					const result = await this.fetchSingleConversation(to.params.token)
					if (!result) {
						// If the conversation is not found, block further navigation,
						// it is handled in the fetchSingleConversation method
						return
					}
				}
				this.$store.dispatch('joinConversation', { token: to.params.token })
			}

			next()
		}

		this.$router.afterEach((to, from) => {
			/**
			 * Update current token in the token store
			 */
			if (from.params.token !== to.params.token) {
				this.tokenStore.updateToken(to.params.token ?? '')
			}

			/**
			 * Fires a global event that tells the whole app that the route has changed. The event
			 * carries the from and to objects as payload
			 */
			EventBus.emit('route-change', { from, to })
		})

		/**
		 * Global before guard, this is called whenever a navigation is triggered.
		 * When app is initializing and router is not ready yet,
		 * first navigation will be made from initial state { name : undefined }
		 */
		this.$router.beforeEach((to, from, next) => {
			if (to.fullPath === from.fullPath) {
				// Block duplicated navigation
				return
			}
			if (from.name === 'conversation' && to.name === 'conversation' && from.params.token === to.params.token) {
				// Navigating within the same conversation
				beforeRouteChangeListener(to, from, next)
			} else if (!this.warnLeaving || this.skipLeaveWarning) {
				// Safe to navigate
				beforeRouteChangeListener(to, from, next)
			} else {
				spawnDialog(ConfirmDialog, {
					name: t('spreed', 'Leave call'),
					message: t('spreed', 'Navigating away from the page will leave the call in {conversation}', {
						conversation: this.currentConversation?.displayName ?? '',
					}),
					buttons: [
						{
							label: t('spreed', 'Stay in call'),
							variant: 'primary',
						},
						{
							label: t('spreed', 'Leave call'),
							variant: 'error',
							callback: () => {
								beforeRouteChangeListener(to, from, next)
							},
						},
					],
				})
			}
			this.skipLeaveWarning = false
		})
	},

	async mounted() {
		this.debounceRefreshCurrentConversation = debounce(this.refreshCurrentConversation, 3000)

		if (!IS_DESKTOP) {
			checkBrowser()
		}
	},

	methods: {
		t,

		refreshCurrentConversation() {
			this.fetchSingleConversation(this.token)
		},

		preventUnload(event) {
			if (!this.warnLeaving && !this.isSendingMessages) {
				return
			}

			event.preventDefault()
		},

		async fetchSingleConversation(token) {
			if (this.isRefreshingCurrentConversation) {
				return
			}
			this.isRefreshingCurrentConversation = true
			let isSuccessfullyFetched = false
			try {
				/**
				 * Fetches a single conversation
				 */
				const response = await this.$store.dispatch('fetchConversation', { token })
				isSuccessfullyFetched = true
				/**
				 * Emits a global event that is used in App.vue to update the page title once the
				 * ( if the current route is a conversation and once the conversations are received)
				 */
				EventBus.emit('conversations-received', { singleConversation: response.data.ocs.data })
			} catch (exception) {
				if (exception.response?.status === 404) {
					console.info('Conversation received, but the current conversation is not in the list. Redirecting to /apps/spreed/not-found')
					this.skipLeaveWarning = true
					this.$router.push({ name: 'notfound' })
				} else if (exception.response?.status === 403) {
					console.info('Attendee/IP address is no longer authorized to participate (banned). Redirecting to /apps/spreed/forbidden')
					this.skipLeaveWarning = true
					this.$router.push({ name: 'forbidden' })
				} else {
					console.error('Error getting room data', exception)
					showError(t('spreed', 'Error occurred when getting the conversation information'))
				}
			} finally {
				this.isRefreshingCurrentConversation = false
			}
			return isSuccessfullyFetched
		},

		// Upon pressing Ctrl+F, focus SearchBox native input in the LeftSidebar
		handleAppSearch() {
			emit('toggle-navigation', {
				open: true,
			})
			this.$nextTick(() => {
				this.$refs.leftSidebar.$refs.searchBox.focus()
			})
		},

		/**
		 * Check if conversation was switched within breakout rooms and parent room.
		 *
		 * @param {string} oldToken The old conversation's token
		 * @param {string} newToken The new conversation's token
		 * @return {boolean}
		 */
		isBreakoutRoomsNavigation(oldToken, newToken) {
			const oldConversation = this.$store.getters.conversation(oldToken)
			const newConversation = this.$store.getters.conversation(newToken)

			// One of rooms is undefined
			if (!oldConversation || !newConversation) {
				return false
			}

			// Parent to breakout
			if (oldConversation.breakoutRoomMode !== CONVERSATION.BREAKOUT_ROOM_MODE.NOT_CONFIGURED
				&& newConversation.objectType === CONVERSATION.OBJECT_TYPE.BREAKOUT_ROOM) {
				return true
			}

			// Breakout to parent
			if (oldConversation.objectType === CONVERSATION.OBJECT_TYPE.BREAKOUT_ROOM
				&& newConversation.breakoutRoomMode !== CONVERSATION.BREAKOUT_ROOM_MODE.NOT_CONFIGURED) {
				return true
			}

			// Breakout to breakout
			return oldConversation.objectType === CONVERSATION.OBJECT_TYPE.BREAKOUT_ROOM && newConversation.objectType === CONVERSATION.OBJECT_TYPE.BREAKOUT_ROOM
		},

		openRoot() {
			if (this.$route.name !== 'root' && !this.isInCall) {
				this.$router.push({ name: 'root' })
			}
		},
	},
}
</script>

<style lang="scss">
/* FIXME: remove after https://github.com/nextcloud/nextcloud-vue/issues/2097 is solved */
.mx-datepicker-main.mx-datepicker-popup {
	z-index: 10001 !important;
}

/* FIXME: Align styles of NcModal header with NcDialog header. Remove if all are migrated */
.modal-wrapper h2.nc-dialog-alike-header {
	font-size: 21px;
	text-align: center;
	height: fit-content;
	min-height: var(--default-clickable-area);
	line-height: var(--default-clickable-area);
	overflow-wrap: break-word;
	margin-block: 0 12px;
}

// Styles for the app content at fullscreen mode
:root:has(body.talk-in-fullscreen) /* Default theme values override */,
body.talk-in-fullscreen /* Theme values override */ {
	--body-container-margin: 0px !important;
	--body-container-radius: 0px !important;
	--header-height: 0px !important;
}

body.talk-in-fullscreen {
	#header {
		display: none !important;
	}
}

// Overwrites styles from public.scss in public conversations
body#body-public {
	--footer-height: 0;
}
</style>

<style lang="scss" scoped>

.content {
	&.in-call {
		:deep(.app-content) {
			background-color: transparent;
		}
	}

	// Fix fullscreen black bar on top
	&:fullscreen {
		padding-top: 0;

		:deep(.app-sidebar) {
			height: 100vh !important;
		}
	}
}

</style>
