#/********************************************************************
# Cayenue/cayenue/main.py 
#
# Copyright (c) 2023  Stephen Rhodes
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
#*********************************************************************/

import os
import sys

from loguru import logger
import hashlib
from time import sleep
from datetime import datetime
import importlib.util
from pathlib import Path
from PyQt6.QtWidgets import QApplication, QMainWindow, QLabel, QSplitter, \
    QTabWidget, QMessageBox, QDialog, QGridLayout, QWidget
from PyQt6.QtCore import pyqtSignal, QObject, QSettings, QDir, QSize, \
    QTimer, Qt, QStandardPaths
from PyQt6.QtGui import QIcon, QGuiApplication, QMovie
from cayenue.panels import CameraPanel, FilePanel, SettingsPanel, VideoPanel, \
    AudioPanel, PicturePanel
from cayenue.enums import ProxyType, Style
from cayenue.glwidget import GLWidget
from cayenue.manager import Manager
from cayenue.player import Player
from cayenue.protocols import ServerProtocols, ClientProtocols, ListenProtocols
from cayenue.components.diskmanager import DiskManager
import kankakee
import platform
import subprocess
import requests
import cayenue
import threading
import argparse
import warnings

warnings.filterwarnings("ignore", category=DeprecationWarning)

if sys.platform == "win32":
    from zipfile import ZipFile
else:
    import tarfile

VERSION = "1.0.3"

class TimerSignals(QObject):
    timeoutPlayer = pyqtSignal(str)

class Timer(QTimer):
    def __init__(self, mw, uri):
        super().__init__()
        self.signals = TimerSignals()
        self.mw = mw
        self.uri = uri
        self.size = None
        self.attempting_reconnect = False
        self.disconnected_time = None
        self.thread_lock = False
        self.timeout.connect(self.createPlayer)
        self.signals.timeoutPlayer.connect(mw.playMedia)
        self.start(10000)

    def __str__(self):
        s = self.uri 
        s += "\nattempting_reconnect: " + str(self.attempting_reconnect)
        s += "\ndisconnected_time: " + str(self.disconnected_time)
        s += "\nisActive: " + str(self.isActive())
        return s
    
    def lock(self):
        # lock protects timer spinner render
        while self.thread_lock:
            sleep(0.001)
        self.thread_lock = True

    def unlock(self):
        self.thread_lock = False
        
    def createPlayer(self):
        self.signals.timeoutPlayer.emit(self.uri)

    def start(self, interval):
        self.attempting_reconnect = True
        if self.disconnected_time is None:
            self.disconnected_time = datetime.now()
        super().start(interval)

    def stop(self):
        self.attempting_reconnect = False
        self.disconnected_time = None
        super().stop()

class WaitDialog(QDialog):
    def __init__(self, p):
        super().__init__(p)
        self.lblMessage = QLabel("Please wait for proxy server to download")
        self.lblProgress = QLabel()
        self.movie = QMovie("image:spinner.gif")
        self.movie.setScaledSize(QSize(50, 50))
        self.lblProgress.setMovie(self.movie)
        self.setWindowTitle("Cayenue")
        if sys.platform == "linux":
            self.setMinimumWidth(350)

        lytMain = QGridLayout(self)
        lytMain.addWidget(self.lblMessage,  0, 1, 1, 1, Qt.AlignmentFlag.AlignCenter)
        lytMain.addWidget(self.lblProgress, 1, 1, 1, 1, Qt.AlignmentFlag.AlignCenter)

        self.movie.start()
        self.setModal(True)

    def sizeHint(self):
        return QSize(300, 100)
    
    def show(self, msg):
        self.lblMessage.setText(msg)
        super().show()
        sleep(0.5)

    def showModal(self, msg):
        self.lblMessage.setText(msg)
        self.exec()

class MainWindowSignals(QObject):
    started = pyqtSignal(str)
    stopped = pyqtSignal(str)
    progress = pyqtSignal(float, str)
    error = pyqtSignal(str)
    reconnect = pyqtSignal(str)
    stopReconnect = pyqtSignal(str)
    setTabIndex = pyqtSignal(int)
    showWaitDialog = pyqtSignal(str)
    hideWaitDialog = pyqtSignal()

class MainWindow(QMainWindow):
    def __init__(self, clear_settings=False, settings_profile="gui", parent_window=None):
        super().__init__()


        self.version = VERSION
        self.logger_id = logger.add(self.getLogFilename(), rotation="1 MB")
        logger.debug(f'starting Cayenue version: {VERSION}')

        self.settings_profile = settings_profile
        self.parent_window = parent_window

        QDir.addSearchPath("image", self.getLocation() + "/cayenue/resources/")
        self.focus_window = None
        self.reader_window = None
        self.external_windows = []
        self.audioLock = False
        self.mediamtx_process = None
        self.http_process = None
        self.viewer_cameras_filled = False
        self.alarm_ordinals = {}
        self.alarm_states = []
        self.last_alarm = None
        self.enable_disk_mgmt = False

        self.program_name = f'Cayenue'
        self.setWindowTitle(self.program_name)
        if sys.platform == "darwin":
            self.setWindowIcon(QIcon('image:Cayenue.png'))
            QGuiApplication.setWindowIcon(QIcon('image:Cayenue.png'))
        else:
            self.setWindowIcon(QIcon('image:Cayenue.png'))
            QGuiApplication.setWindowIcon(QIcon('image:Cayenue.png'))

        self.settings = QSettings("Cayenue", settings_profile)
        logger.debug(f'Settings loaded from file {self.settings.fileName()} using format {self.settings.format()}')
        if clear_settings:
            self.settings.clear()
        self.geometryKey = "MainWindow/geometry"
        self.splitKey = "MainWindow/split"
        self.collapsedKey = "MainWindow/collapsed"
        self.closing = False
        self.dlgWait = WaitDialog(self)
        self.signals = MainWindowSignals()

        self.pm = Manager(self)
        self.diskManager = DiskManager(self)
        self.timers = {}

        self.proxies = {}
        self.proxy = None
        self.server = None
        self.serverProtocols = ServerProtocols(self)
        self.client = None
        self.clientProtocols = ClientProtocols(self)

        self.broadcaster = None
        self.listener = None
        self.listenProtocols = ListenProtocols(self)

        self.settingsPanel = SettingsPanel(self)
        self.signals.started.connect(self.settingsPanel.onMediaStarted)
        self.signals.stopped.connect(self.settingsPanel.onMediaStopped)
        self.glWidget = GLWidget(self)
        self.cameraPanel = CameraPanel(self)
        self.signals.started.connect(self.cameraPanel.onMediaStarted)
        self.signals.stopped.connect(self.cameraPanel.onMediaStopped)
        self.filePanel = FilePanel(self)
        self.filePanel.control.setBtnMute()
        self.filePanel.control.setSldVolume()
        self.picturePanel = PicturePanel(self)
        if not sys.platform == "win32":
            self.signals.started.connect(self.picturePanel.onMediaStarted)
        self.signals.progress.connect(self.picturePanel.onMediaProgress)
        self.signals.stopped.connect(self.picturePanel.onMediaStopped)
        self.signals.started.connect(self.filePanel.onMediaStarted)
        self.signals.stopped.connect(self.filePanel.onMediaStopped)
        self.signals.progress.connect(self.filePanel.onMediaProgress)
        self.videoPanel = VideoPanel(self)
        self.audioPanel = AudioPanel(self)
        self.signals.error.connect(self.onError)
        self.signals.reconnect.connect(self.startReconnectTimer)
        self.signals.stopReconnect.connect(self.stopReconnectTimer)
        self.signals.showWaitDialog.connect(self.showWaitDialog)
        self.signals.hideWaitDialog.connect(self.hideWaitDialog)

        self.STD_FILE_DURATION = self.settingsPanel.storage.spnMaxFileDuration.value() * 60

        self.tab = QTabWidget()
        hideCameras = bool(int(self.settings.value(self.filePanel.control.hideCameraKey, 0)))
        if not hideCameras:
            self.tab.addTab(self.cameraPanel, "Cameras")
        if settings_profile == "Reader":
            self.tab.addTab(self.picturePanel, "Pictures")
            self.tab.addTab(self.filePanel, "Videos")
        if not hideCameras:
            self.tab.addTab(self.settingsPanel, "Settings")
        if self.settingsPanel.proxy.generateAlarmsLocally() and not hideCameras:
            self.tab.addTab(self.videoPanel, "Video")
            self.tab.addTab(self.audioPanel, "Audio")
        self.tabVisible = True
        self.tabIndex = 0
        self.tabBugFix = False

        display = QWidget()
        lytDisplay = QGridLayout(display)
        lytDisplay.addWidget(self.glWidget, 0, 0, 1, 1)
        lytDisplay.setContentsMargins(0, 0, 0, 0)

        self.split = QSplitter()
        self.split.addWidget(display)
        self.split.addWidget(self.tab)
        self.split.setStretchFactor(0, 10)
        self.split.splitterMoved.connect(self.splitterMoved)
        self.setCentralWidget(self.split)

        rect = self.settings.value(self.geometryKey)
        if rect is not None:
            screen = QGuiApplication.screenAt(rect.topLeft())
            if screen:
                self.setGeometry(rect)

        if remote := self.settingsPanel.proxy.proxyRemote:
            comps = remote[:len(remote)-1][7:].split(":")
            ip_addr = comps[0]
            try:
                self.initializeClient(ip_addr)
            except Exception as ex:
                logger.error(f'Unable to configure client {ex}')

        self.videoWorkerHook = None
        self.videoWorker = None
        self.videoConfigureHook = None
        self.videoConfigure = None
        if self.settingsPanel.proxy.generateAlarmsLocally():
            videoWorkerName = self.videoPanel.cmbWorker.currentText()
            if len(videoWorkerName) > 0:
                self.loadVideoConfigure(videoWorkerName)

        self.audioWorkerHook = None
        self.audioWorker = None
        self.audioConfigureHook = None
        self.audioConfigure = None
        if self.settingsPanel.proxy.generateAlarmsLocally():
            audioWorkerName = self.audioPanel.cmbWorker.currentText()
            if len(audioWorkerName) > 0:
                self.loadAudioConfigure(audioWorkerName)

        try:
            if_addrs = self.settingsPanel.proxy.getInterfaces()
            if self.settingsPanel.proxy.proxyType == ProxyType.CLIENT:
                if self.settingsPanel.proxy.chkListen.isChecked():
                    self.startListener(if_addrs)
        except Exception as ex:
            logger.error(f'Unable to initialize multicast {ex}')

        appearance = self.settingsPanel.general.cmbAppearance.currentText()
        if appearance == "Dark":
            self.setStyleSheet(self.style(Style.DARK))
        if appearance == "Light":
            self.setStyleSheet(self.style(Style.LIGHT))

        collapsed_default_value = 0
        if self.settings_profile == "Focus":
            collapsed_default_value = 1
        collapsed = int(self.settings.value(self.collapsedKey, collapsed_default_value))
        if collapsed:
            self.collapseSplitter()
        else:
            self.restoreSplitter()

        logger.debug(f'FFMPEG VERSION: {Player("", self).getFFMPEGVersions()}')

    def specVideo(self, workerLocation):
        spec = importlib.util.spec_from_file_location("VideoConfigure", workerLocation)
        sys.modules["VideoConfigure"] = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(sys.modules["VideoConfigure"])
        #sleep(0.5)
        self.signals.hideWaitDialog.emit()

    def loadVideoConfigure(self, workerName):
        workerLocation = str(Path(self.videoPanel.stdLocation) / workerName)
        if workerName == "motion.py":
            # weird linux bug that shows dlgWait delayed causing missed signal hideWaitDialog, see sleep in specVideo
            self.specVideo(workerLocation)
        else:
            if sys.platform == "win32" or (sys.platform == "linux" and self.isVisible()) or sys.platform == "darwin":
                thread = threading.Thread(target=self.specVideo, args=(workerLocation,))
                thread.start()
                self.signals.showWaitDialog.emit("Please wait for modules to load")
            else:
                self.specVideo(workerLocation)

        self.videoConfigure = sys.modules["VideoConfigure"].VideoConfigure(self)
        self.cameraPanel.lstCamera.currentItemChanged.connect(self.videoConfigure.setCamera)
        self.videoPanel.setPanel(self.videoConfigure)

    def pyVideoCallback(self, frame, player):
        if not self.videoWorkerHook:
            file = str(Path(self.videoPanel.stdLocation) / self.videoPanel.cmbWorker.currentText())
            spec = importlib.util.spec_from_file_location("VideoWorker", file)
            self.videoWorkerHook = importlib.util.module_from_spec(spec)
            sys.modules["VideoWorker"] = self.videoWorkerHook
            spec.loader.exec_module(self.videoWorkerHook)

        if self.videoWorkerHook:
            if not self.videoWorker:
                self.videoWorker = self.videoWorkerHook.VideoWorker(self)

        if self.videoWorker:
            return self.videoWorker(frame, player)

    def loadAudioConfigure(self, workerName):
        spec = importlib.util.spec_from_file_location("AudioConfigure", self.audioPanel.stdLocation + "/" + workerName)
        audioConfigureHook = importlib.util.module_from_spec(spec)
        sys.modules["AudioConfigure"] = audioConfigureHook
        spec.loader.exec_module(audioConfigureHook)
        self.audioConfigure = audioConfigureHook.AudioConfigure(self)
        self.cameraPanel.lstCamera.currentItemChanged.connect(self.audioConfigure.setCamera)
        self.audioPanel.setPanel(self.audioConfigure)
    
    def pyAudioCallback(self, frame, uri):
        try:
            player = self.pm.getPlayer(uri)
            if player.analyze_audio:

                while self.audioLock:
                    sleep(0.001)
                self.audioLock = True

                if self.audioWorkerHook is None:
                    workerName = self.audioPanel.cmbWorker.currentText()
                    spec = importlib.util.spec_from_file_location("AudioWorker", self.audioPanel.stdLocation + "/" + workerName)
                    self.audioWorkerHook = importlib.util.module_from_spec(spec)
                    sys.modules["AudioWorker"] = self.audioWorkerHook
                    spec.loader.exec_module(self.audioWorkerHook)
                    self.audioWorker = None

                if self.audioWorkerHook:
                    if not self.audioWorker:
                        self.audioWorker = self.audioWorkerHook.AudioWorker(self)
                    
                if self.audioWorker:
                    self.audioWorker(frame, player)
                
                self.audioLock = False

            else:
                if player.uri == self.glWidget.focused_uri:
                    if self.audioWorker:
                        self.audioWorker(None, None)
        except Exception as ex:
            logger.error(f'Audio callback error: {ex}')

    def playMedia(self, uri, file_start_from_seek=-1.0):
        if not uri:
            logger.debug(f'Attempt to create player with null uri')
            return

        if self.settings_profile == "Focus":
            self.closeAllStreams()
    
        player = Player(uri, self)

        player.mediaPlayingStarted = self.mediaPlayingStarted
        player.mediaPlayingStopped = self.mediaPlayingStopped
        player.errorCallback = self.errorCallback
        player.infoCallback = self.infoCallback
        player.str_video_filter = "format=rgb24"
        player.renderCallback = self.glWidget.renderCallback
        player.str_hw_device_type = self.settingsPanel.general.cmbDecoder.currentText()
        player.audio_driver_index = self.settingsPanel.general.cmbAudioDriver.currentIndex()

        if player.isCameraStream():
            if profile := self.cameraPanel.getProfile(uri):
                player.buffer_size_in_seconds = self.settings.value(self.settingsPanel.alarm.bufferSizeKey, 10)
                player.onvif_frame_rate.num = profile.frame_rate()
                player.onvif_frame_rate.den = 1
                player.disable_audio = profile.getDisableAudio()
                player.disable_video = profile.getDisableVideo()
                player.hidden = profile.getHidden()
                if not player.hidden:
                    player.packetDrop = self.packetDrop
                    player.last_render = datetime.now()
                    player.pyAudioCallback = self.pyAudioCallback
                    player.desired_aspect = profile.getDesiredAspect()
                    player.analyze_video = profile.getAnalyzeVideo()
                    player.analyze_audio = profile.getAnalyzeAudio()
                    if camera := self.cameraPanel.getCamera(uri):
                        player.setVolume(camera.volume)
                        player.setMute(camera.mute)
            else:
                logger.error(f'{self.settings_profile} play media profile was not found: {uri}')
        else:
            player.live_stream = False
            player.file_start_from_seek = file_start_from_seek
            player.request_reconnect = False
            player.setVolume(self.filePanel.getVolume())
            player.setMute(self.filePanel.getMute())
            player.progressCallback = self.mediaProgress

        if not player.isCameraStream():
            while self.pm.hasFilePlayers():
                sleep(0.01)
                
        self.pm.startPlayer(player)

        # this is to prevent noise bands on the display during initial focus window launch
        if self.settings_profile == "Focus":
            self.glWidget.force_clear = True

    def keyPressEvent(self, event):
        match event.key():
            case Qt.Key.Key_Escape:
                if self.settings_profile == "gui":
                    self.showNormal()
                elif self.settings_profile == "Focus":
                    self.close()
            case Qt.Key.Key_F12:
                if self.isFullScreen():
                    self.showNormal()
                else:
                    self.showFullScreen()
            case Qt.Key.Key_F11:
                if self.isSplitterCollapsed():
                    self.restoreSplitter()
                    camera = self.cameraPanel.getCurrentCamera()
                    if camera:
                        self.glWidget.focused_uri = camera.uri()
                else:
                    self.glWidget.focused_uri = None
                    self.collapseSplitter()
            case Qt.Key.Key_D:
                if self.settings_profile == "gui":
                    if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
                        self.cameraPanel.btnDiscoverClicked()
                        self.cameraPanel.lstCamera.setFocus()

        super().keyPressEvent(event)

    def showEvent(self, event):
        splitterState = self.settings.value(self.splitKey)
        if not splitterState:
            self.splitterMoved(0, 0)

        if self.settingsPanel.general.chkStartFullScreen.isChecked():
            self.showFullScreen()

        if not self.split.sizes()[0]:
            self.settingsPanel.general.btnHideDisplay.setText("Show Display")

        if self.settingsPanel.discover.chkAutoDiscover.isChecked():
            self.cameraPanel.btnDiscoverClicked()

        # this will clear display artifacts on launch
        self.glWidget.last_player_count = 1

        if self.settings_profile == "Reader":
            self.picturePanel.tree.setFocus()
        elif self.settings_profile == "gui":
            self.cameraPanel.lstCamera.setFocus()

        super().showEvent(event)

    def resizeEvent(self, event):
        if self.split.sizes()[0]:
            self.settingsPanel.general.btnHideDisplay.setText("Hide Display")
        else:
            self.settingsPanel.general.btnHideDisplay.setText("Show Display")
        super().resizeEvent(event)

    def startAllCameras(self):
        lstCamera = self.cameraPanel.lstCamera
        if not lstCamera: return
        try:
            cameras = [lstCamera.item(x) for x in range(lstCamera.count())]
            for camera in cameras:
                self.cameraPanel.setCurrentCamera(camera.uri())
                self.cameraPanel.onItemDoubleClicked(camera)
        except Exception as ex:
            logger.error(f'Start all cameras error : {ex}')

    def closeAnyPlayingFiles(self):
        self.pm.lock()
        for player in self.pm.players.values():
            if not player.isCameraStream():
                player.requestShutdown()
        self.pm.unlock()

    def closeAllStreams(self):
        try:
            for timer in self.timers.values():
                self.signals.stopReconnect.emit(timer.uri)
                timer.stop()

            self.pm.lock()
            for player in self.pm.players.values():
                player.requestShutdown()
            self.pm.unlock()

            lstCamera = self.cameraPanel.lstCamera
            if lstCamera:
                cameras = [lstCamera.item(x) for x in range(lstCamera.count())]
                for camera in cameras:
                    camera.setIconIdle()

            self.pm.waitForAllPlayersClosed()
            self.pm.ordinals.clear()
            self.pm.sizes.clear()

            if not self.closing:
                self.cameraPanel.syncGUI()
                
                if self.settingsPanel:
                    if self.settingsPanel.general:
                        self.settingsPanel.general.btnCloseAll.setText("Start All")

        except Exception as ex:
            logger.error(f'Close all streams error : {ex}')

    def closeEvent(self, event):
        try:
            self.closing = True
            self.closeAllStreams()
            self.stopProxyServer()
            self.stopHttpServer()
            self.stopOnvifServer()

            self.settings.setValue(self.geometryKey, self.geometry())

            if self.focus_window:
                self.focus_window.close()
                sleep(0.1)

            for window in self.external_windows:
                window.close()
                sleep(0.1)

            super().closeEvent(event)

        except Exception as ex:
            logger.error(f'Window close error : {ex}')

    def showWaitDialog(self, str):
        self.dlgWait.lblMessage.setText(str)
        self.dlgWait.exec()

    def hideWaitDialog(self):
        self.dlgWait.hide()

    def openFocusWindow(self):
        if not self.focus_window:
            proxy = None
            match self.settingsPanel.proxy.proxyType:
                case ProxyType.CLIENT:
                    proxy = self.settingsPanel.proxy.txtRemote.text()
                case ProxyType.SERVER:
                    proxy = self.settingsPanel.proxy.lblServer.text().split()[0]
            focus_settings = QSettings("Cayenue", "Focus")
            focus_settings.setValue("settings/proxyType", ProxyType.CLIENT)
            focus_settings.setValue("settings/proxyRemote", proxy)
            focus_settings.setValue("settings/autoDiscover", 1)

            # some cameras have problem streams that force the use of alternate choices for
            # display and record profiles, force the focus window to use those settings
            keys = self.settings.allKeys()
            for key in keys:
                parts = key.split("/")
                if len(parts) > 1:
                    if parts[1] == "RecordProfile":
                        value = self.settings.value(key)
                        camera = parts[0]
                        focus_key = f'{camera}/DisplayProfile'
                        focus_settings.setValue(focus_key, value)
                        focus_key = f'{camera}/RecordProfile'
                        focus_settings.setValue(focus_key, value)
            self.focus_window = cayenue.main.MainWindow(settings_profile="Focus")
        self.focus_window.show()

    def mediaPlayingStarted(self, uri):
        try:
            if self.isCameraStreamURI(uri):
                if profile := self.cameraPanel.getProfile(uri):
                    profile_type = ""
                    if camera := self.cameraPanel.getCamera(uri):
                        if camera.isDisplayProfile(uri):
                            profile_type = "Display Profile"
                        else:
                            profile_type = "Record Profile"
                    
                    window_name = self.settings_profile
                    if self.settings_profile == "gui":
                        window_name = "Main"

                    if self.settings_profile == "gui":
                        logger.debug(f'Camera stream opened {self.getCameraName(uri)}, stream_uri : {profile.stream_uri()}, serial_number : {profile.serial_number()}, resolution : {profile.width()} x {profile.height()}, fps: {profile.frame_rate()}, {profile_type}, Window: {window_name}')

                if player := self.pm.getPlayer(uri):

                    if player.marked_for_death:
                        player.terminate()
                        return

                    if player.systemTabSettings():
                        if player.systemTabSettings().record_enable and player.systemTabSettings().record_always:
                            if camera := self.cameraPanel.getCamera(uri):
                                record = False
                                if camera.displayProfileIndex() != camera.recordProfileIndex():
                                    if camera.isRecordProfile(uri):
                                        record = True
                                else:
                                    if camera.profiles[camera.displayProfileIndex()].uri() == uri:
                                        record = True
                                if record:
                                    if filename := player.getOutputFilename():
                                        player.toggleRecording(filename)

            self.signals.stopReconnect.emit(uri)
            self.signals.started.emit(uri)

        except Exception as ex:
            logger.error(f'Exception occured during callback media playing started: {ex}')

    def mediaPlayingStopped(self, uri):
        try:
            if player := self.pm.getPlayer(uri):
                request_reconnect = player.request_reconnect
                self.pm.removePlayer(uri)
        
                if request_reconnect:
                    showMessage = True
                    if timer := self.timers.get(uri):
                        if timer.attempting_reconnect:
                            showMessage = False
                    if showMessage:
                        logger.debug(f'Camera stream closed with reconnect requested {self.getCameraName(uri)}')
                    self.signals.reconnect.emit(uri)
                else:
                    if self.isCameraStreamURI(uri):
                        if self.settings_profile == "gui":
                            logger.debug(f'Stream closed {self.getCameraName(uri)}')


                if self.signals:
                    self.signals.stopped.emit(uri)

        except Exception as ex:
            logger.error(f'Exception occurred during callback media playing stopped: {ex}')

    def startReconnectTimer(self, uri):
        if uri in self.timers:
            self.timers[uri].start(10000)
        else:
            self.timers[uri] = Timer(self, uri)
        self.cameraPanel.syncGUI()

    def stopReconnectTimer(self, uri):
        if timer := self.timers.get(uri, None):
            timer.lock()
            timer.stop()
            timer.unlock()

    def getActiveTimerCount(self):
        count = 0
        for timer in self.timers.values():
            if timer.isActive():
                count += 1
        return count

    def infoCallback(self, msg, uri):
        try:
            if msg == "player audio disabled":
                return
            if msg == "player video disabled":
                return
            if msg == "dropping frames due to buffer overflow":
                return
            if msg.startswith("Pipe opened write file:"):
                return
            if msg.startswith("Pipe closed file:"):
                return
            if msg == "NO AUDIO STREAM FOUND":
                return
            
            if "Reader seek exception: av_seek_frame has failed with error: Operation not permitted" in msg:
                err_msg = f'Unable to seek to requested time in file {uri}. The file is highlighted in the file panel, you can start it manually.'
                self.signals.error.emit(err_msg)
                return

            name = ""
            if self.isCameraStreamURI(uri):
                camera = self.cameraPanel.getCamera(uri)
                if camera:
                    name = f'Camera: {self.getCameraName(uri)}'
            else:
                name = f'File: {uri}'

            if msg.startswith("Output file creation failure") or \
            msg.startswith("Record to file close error"):
                logger.error(f'{name}, Message: {msg}')
                return

            if msg.startswith("Reader terminated by timeout"):
                logger.error(f'{name}, Message: {msg}')

            if msg.startswith("Using SDL audio driver"):
                logger.debug(msg)
                return

            print(f'{name}, Message: {msg}')
        except Exception as ex:
            logger.error(f'Exception occured during info callback: {ex}')

    def errorCallback(self, msg, uri, reconnect):
        try:
            if reconnect:
                camera_name = ""
                last_msg = ""

                if "SDL_OpenAudioDevice error" in msg:
                    if profile := self.cameraPanel.getProfile(uri):
                        profile.setDisableAudio(True)
                        player.disable_audio = True
                        self.cameraPanel.tabVideo.syncGUI()

                if camera := self.cameraPanel.getCamera(uri):
                    camera_name = self.getCameraName(uri)
                    last_msg = camera.last_msg
                    camera.last_msg = msg
                    self.signals.reconnect.emit(uri)

                if msg != last_msg:
                    logger.error(f'Error from camera: {camera_name} : {msg}, attempting to re-connect')
            else:
                name = ""
                last_msg = ""
                if self.isCameraStreamURI(uri):
                    if player := self.pm.getPlayer(uri):
                        player.requestShutdown()
                        last_msg = player.last_msg
                        player.last_msg = msg

                    if camera := self.cameraPanel.getCamera(uri):
                        if c_uri := camera.companionURI(uri):
                            if c_player := self.pm.getPlayer(c_uri):
                                c_player.requestShutdown()

                        name = f'Camera: {self.getCameraName(uri)}'
                        camera.setIconIdle()
                        self.cameraPanel.syncGUI()
                        self.cameraPanel.setTabsEnabled(True)
                    
                    if msg != last_msg:
                        self.signals.error.emit(msg)

                else:
                    self.signals.error.emit(msg)

                logger.error(f'{name}, Error: {msg}')
        except Exception as ex:
            logger(f'exception occured during error handling: {ex}')

    def mediaProgress(self, pct, uri):
        self.signals.progress.emit(pct, uri)

    def packetDrop(self, uri):
        try:
            if player := self.pm.getPlayer(uri):
                frames = 10
                if player.onvif_frame_rate.num and player.onvif_frame_rate.den:
                    frames = int((player.onvif_frame_rate.num / player.onvif_frame_rate.den) * 2)
                player.packet_drop_frame_counter = frames
        except Exception as ex:
            logger.error(f'Packet drop error: {ex}')

    def onError(self, msg):
        if not self.closing:
            msgBox = QMessageBox(self)
            msgBox.setText(msg)
            msgBox.setWindowTitle(self.program_name)
            msgBox.setIcon(QMessageBox.Icon.Warning)
            msgBox.exec()
            self.cameraPanel.syncGUI()
            self.cameraPanel.setEnabled(True)

    def isSplitterCollapsed(self):
        return self.split.sizes()[1] == 0

    def collapseSplitter(self):
        self.split.setSizes([self.split.frameSize().width(), 0])
        self.settings.setValue(self.collapsedKey, 1)
        self.tabVisible = False
    
    def splitterMoved(self, pos, index):
        if self.split.sizes()[1]:
            self.settings.setValue(self.collapsedKey, 0)
            self.settings.setValue(self.splitKey, self.split.saveState())
            self.tabVisible = True
        else:
            self.settings.setValue(self.collapsedKey, 1)
            self.tabVisible = False

    def restoreSplitter(self):
        self.settings.setValue(self.collapsedKey, 0)
        splitterState = self.settings.value(self.splitKey)
        if splitterState is not None:
            self.split.restoreState(splitterState)
        else:
            dims = self.size()
            self.split.setSizes([int(dims.width()*0.75), int(dims.width()*0.25)])

    def getCameraName(self, uri):
        result = ""
        camera = self.cameraPanel.getCamera(uri)
        if camera:
            result = camera.text() + " (" + camera.profileName(uri) + ")"
        return result
    
    def getLocation(self):
        path = Path(os.path.dirname(__file__))
        return str(path.parent.absolute())
    
    def getCacheLocation(self):
        match sys.platform:
            case "linux":
                if len(QStandardPaths.standardLocations(QStandardPaths.StandardLocation.AppDataLocation)):
                    return QStandardPaths.standardLocations(QStandardPaths.StandardLocation.AppDataLocation)[0]
                else:
                    return os.path.join(os.environ['HOME'], ".cache", "cayenue")
            case "win32":
                return os.path.join(os.environ['HOMEPATH'], ".cache", "cayenue")
            case "darwin":
                return os.path.join(self.getLocation(), "cache")

        # fallback if all else fails
        return ".cache"

    def getLogFilename(self):
        return os.path.join(self.getCacheLocation(), "logs", "logs.txt")
    
    def isCameraStreamURI(self, uri):
        result = False
        if uri:
            result = uri.lower().startswith("rtsp") or uri.lower().startswith("http")
        return result
    
    def manageBroadcaster(self, if_addrs):
        # an empty list for if_addrs will disable broadcaster
        if self.broadcaster:
            del self.broadcaster
            self.broadcaster = None
        try:
            if len(if_addrs):
                self.broadcaster = kankakee.Broadcaster(if_addrs)
                self.broadcaster.errorCallback = self.listenProtocols.error
                self.broadcaster.enableLoopback(False)
        except Exception as ex:
            logger.error(f'Error initializing broadcaster : {ex}')

    def startListener(self, if_addrs):
        if not self.settings_profile == "gui":
            return
                        
        ip_addr = None
        if len(if_addrs):
            ip_addr = if_addrs[0]

        if remote := self.settingsPanel.proxy.proxyRemote:
            comps = remote[:len(remote)-1][7:].split(":")
            rmt_addr = comps[0]
            rmt = rmt_addr.split(".")
            if ip_addr:
                found = False
                lcl = ip_addr.split(".")
                if len(rmt) == len(lcl):
                    for addr in if_addrs:
                        lcl = addr.split(".")
                        if rmt[0] == lcl[0] and rmt[1] == lcl[1] and rmt[2] == lcl[2]:
                            found = True
                            ip_addr = addr
                            break
                if not found:
                    QMessageBox.warning(self, "Listener Error", "Unable to Start Event Listener\nPlease check proxy configuration")
                    return

        try:
            if self.listener:
                logger.debug("Found existing Alarm Listener, terminating")
                self.stopListener()
            self.listener = kankakee.Listener([ip_addr])
            self.listener.listenCallback = self.listenProtocols.callback
            self.listener.errorCallback = self.listenProtocols.error
            if not self.listener.running:
                self.listener.start()
                logger.debug("Alarm Listener was started successfully")
        except Exception as ex:
            logger.error(f'Error starting Alarm Listener : {ex}')

    def stopListener(self):
        if self.listener:
            try:
                self.listener.stop()
            except Exception as ex:
                logger.error(f'Error stopping Alarm Listener : {ex}')
            
    
    def initializeClient(self, ip_addr):
        try:
            self.client = kankakee.Client(ip_addr, 8550)
            self.client.timeout = self.settingsPanel.proxy.spnTimeout.value()
            self.client.clientCallback = self.clientProtocols.callback
            self.client.errorCallback = self.clientProtocols.error
        except Exception as ex:
            logger.error(f'Error initializing Onvif Client : {ex}')

    def startOnvifServer(self, ip):
        # if ip is an empty string, bind server to IPADDR_ANY, otherwise bind to ip address
        try:
            if not self.server:
                self.server = kankakee.Server(ip, 8550)
                self.server.serverCallback = self.serverProtocols.callback
                self.server.errorCallback = self.serverProtocols.error
            if not self.server.running:
                self.server.start()
        except Exception as ex:
            logger.error(f'Error starting Onvif Server : {ex}')

    def stopOnvifServer(self):
        try:
            if self.server:
                self.server.stop()
        except Exception as ex:
            logger.error(f'Error stopping Onvif Server : {ex}')
    
    def calculate_sha256(self, filename):
        try:
            with open(filename, 'rb') as file:
                hasher = hashlib.sha256()
                while chunk := file.read(4096):
                    hasher.update(chunk)
            return hasher.hexdigest()
        except Exception as ex:
            logger.error(f'An error occurred computing the hash for {filename}: {ex}')
            return None
        
    def downloadProxyServer(self, dir):

        version = "v1.15.5"
        hashes = {
            f"mediamtx_{version}_darwin_amd64.tar.gz": "6418cf6df9f00b04f3814c54a35d8f8a5e6625a187b5b51622feb070b71527ac",
            f"mediamtx_{version}_darwin_arm64.tar.gz": "116150e6900ed2ae845cf5113fab8007ad639a36d80c1a09c56e091ddcc5b907",
            f"mediamtx_{version}_linux_amd64.tar.gz": "158ebff59739d723a5bb60a7c6c42d908d378609f6237a94e583edd5456f7322",
            f"mediamtx_{version}_linux_arm64.tar.gz": "515f08cb99add42e66fc6f897f19942a3ee319bb001ef509e6e7ffdd5031f162",
            f"mediamtx_{version}_linux_armv6.tar.gz": "d59047e71a43800ea5c986035d5e63b84c65935c478a22399a513573d0d6d53c",
            f"mediamtx_{version}_linux_armv7.tar.gz": "4a47a4208cb68216a98b6b9bc40ce5925455276dedc63c1fb6834b4fa6f947f1",
            f"mediamtx_{version}_windows_amd64.zip": "19cd9d1fbb76225380859109175b7547d2e68b4b70858be4fa565604743acf8d"
        }

        try:
            if not os.path.exists(dir):
                os.makedirs(dir)

            logger.debug('Attempting to download MediaMTX server to directory {dir}')
            
            architecture = None
            match platform.machine():
                case "AMD64":
                    architecture = "amd64"
                case "x86_64":
                    architecture = "amd64"
                case "arm64":
                    architecture = "arm64"
                case "aarch64":
                    architecture = "arm64v8"

            operating_system = None
            match sys.platform:
                case "linux":
                    operating_system = "linux"
                case "darwin":
                    operating_system = "darwin"
                case "win32":
                    operating_system = "windows"

            home = "https://github.com/bluenviron/mediamtx/releases/download"
            suffix = "tar.gz"
            if operating_system == "windows":
                suffix = "zip"

            id = f'mediamtx_{version}_{operating_system}_{architecture}.{suffix}'
            url = None
            if architecture and operating_system:
                url = f'{home}/{version}/{id}'
            else:
                raise AttributeError(f'Unable to determine MediaMTX server for operating system for {sys.platform} and architecture {platform.machine()}')

            if url:
                download_filename = os.path.join(dir, id)
                logger.debug(f'Downloading MediaMTX from {url} to {download_filename}')
            
                response = requests.get(url, allow_redirects=True, timeout=(10, 120))
                if not response:
                    raise RuntimeError(f'Error downloading {url}: {response.status_code}')
                
                with open(download_filename, 'wb') as content:
                    content.write(response.content)

                if os.path.isfile(download_filename):
                    verified = False
                    if hash := self.calculate_sha256(download_filename):
                        if hash == hashes.get(id, None):
                            verified = True
                    if not verified:
                        os.remove(download_filename)
                        raise RuntimeError(f'Unable to verify {id}')
                    
                    logger.debug(f'MediaMTX {url} compressed file was downloaded and verified successfully')

                    archive = None
                    if sys.platform == "win32":
                        archive = ZipFile(download_filename, 'r')
                        archive.extractall(dir)
                    else:
                        archive = tarfile.open(download_filename)
                        archive.extractall(dir, filter='data')
                    if archive:
                        archive.close()
                    else:
                        raise RuntimeError("Unable to open decompression utility for MediaMTX")
                    os.remove(download_filename)

                    config_filename = os.path.join(dir, "mediamtx.yml")
                    with open(config_filename, 'r') as file:
                        contents = file.read()

                    contents = contents.replace("srt: yes", "srt: no")
                    #contents = contents.replace("webrtc: yes", "webrtc: no")
                    contents = contents.replace("hls: yes", "hls: no")
                    contents = contents.replace("rtmp: yes", "rtmp: no")

                    with open(config_filename, 'w') as file:
                        file.write(contents) 

        except Exception as ex:
            self.signals.hideWaitDialog.emit()
            raise RuntimeError(f'Unable to download and configure MediaMTX {ex}')
        
        self.signals.hideWaitDialog.emit()

    def startProxyServer(self, autoDownload, dir):
        try:
            executable_filename = f'{dir}/mediamtx'
            if sys.platform == "win32":
                executable_filename += ".exe"
            config_filename = f'{dir}/mediamtx.yml'

            if not os.path.isfile(executable_filename) or not os.path.isfile(config_filename):
                if not autoDownload:
                    self.signals.error.emit(f'Error: cannot find MediaMTX in {dir}, please use auto download selection in Settings -> Proxy')
                    return

                if self.isVisible():
                    thread = threading.Thread(target=self.downloadProxyServer, args=(dir,))
                    thread.start()
                    self.signals.showWaitDialog.emit("Please wait for MediaMTX binary to download")
                else:
                    self.downloadProxyServer(dir)

            if os.path.isfile(executable_filename) and os.path.isfile(config_filename):
                if not self.mediamtx_process:
                    self.mediamtx_process = subprocess.Popen([executable_filename, config_filename], start_new_session=True)
                    sleep(1)
            else:
                raise RuntimeError("Unknown error has occurred in starting MediaMTX proxy server")

        except Exception as ex:
            logger.error(f'Error starting proxy server: {ex}')
            self.signals.error.emit(f'Error starting proxy server: {ex}')

    def stopProxyServer(self):
        if self.mediamtx_process:
            self.mediamtx_process.terminate()
            self.mediamtx_process.wait()
            self.mediamtx_process = None
            logger.debug("Proxy server stopped")

    def startHttpServer(self):
        try:
            if not self.http_process:
                self.http_process = subprocess.Popen([sys.executable, f'{os.path.dirname(os.path.realpath(__file__))}/server.py'], env=os.environ.copy(), start_new_session=True)
                return_code = self.http_process.returncode
                print("RC", return_code)
                logger.debug("starting http server from dir", os.path.dirname(os.path.realpath(__file__)))
        except Exception as ex:
            logger.error(f'Error starting http server: {ex}')
            self.signals.error.emit(f'Error starting http server: {ex}')

    def stopHttpServer(self):
        if self.settings_profile == "Reader":
            return
        try:
            requests.post("http://127.0.0.1:8800/shutdown", timeout=1)
        except Exception:
            pass

        if self.http_process:
            self.http_process.terminate()
            self.http_process.wait(timeout=5)
            self.http_process = None
            logger.debug("Http server stopped")

    def getProxyURI(self, arg):
        return self.proxies.get(arg, arg)
    
    def addCameraProxy(self, camera):
        match self.settingsPanel.proxy.proxyType:
            case ProxyType.SERVER:
                for profile in camera.profiles:
                    if_addr = None
                    if len(self.settingsPanel.proxy.if_addrs):
                        if_addr = self.settingsPanel.proxy.if_addrs[0]
                    self.proxies[profile.stream_uri()] = f'rtsp://{if_addr}:8554/{camera.serial_number()}/{profile.profile()}'
            case ProxyType.CLIENT:
                for profile in camera.profiles:
                    server = self.settingsPanel.proxy.txtRemote.text()
                    self.proxies[profile.stream_uri()] = f'{server}{camera.serial_number()}/{profile.profile()}'

    def style(self, appearance):
        filename = self.getLocation() + "/cayenue/resources/darkstyle.qss"
        with open(filename, 'r') as file:
            strStyle = file.read()

        match appearance:
            case Style.DARK:
                blDefault = "#5B5B5B"
                bmDefault = "#4B4B4B"
                bdDefault = "#3B3B3B"
                flDefault = "#C6D9F2"
                fmDefault = "#9DADC2"
                fdDefault = "#808D9E"
                slDefault = "#FFFFFF"
                smDefault = "#DDEEFF"
                sdDefault = "#306294"
                isDefault = "#323232"
                strStyle = strStyle.replace("background_light",  blDefault)
                strStyle = strStyle.replace("background_medium", bmDefault)
                strStyle = strStyle.replace("background_dark",   bdDefault)
                strStyle = strStyle.replace("foreground_light",  flDefault)
                strStyle = strStyle.replace("foreground_medium", fmDefault)
                strStyle = strStyle.replace("foreground_dark",   fdDefault)
                strStyle = strStyle.replace("selection_light",   slDefault)
                strStyle = strStyle.replace("selection_medium",  smDefault)
                strStyle = strStyle.replace("selection_dark",    sdDefault)
                strStyle = strStyle.replace("selection_item",    isDefault)
            case Style.LIGHT:
                blDefault = "#AAAAAA"
                bmDefault = "#CCCCCC"
                bdDefault = "#FFFFFF"
                flDefault = "#111111"
                fmDefault = "#222222"
                fdDefault = "#999999"
                slDefault = "#111111"
                smDefault = "#222222"
                sdDefault = "#999999"
                isDefault = "#888888"
                strStyle = strStyle.replace("background_light",  blDefault)
                strStyle = strStyle.replace("background_medium", bmDefault)
                strStyle = strStyle.replace("background_dark",   bdDefault)
                strStyle = strStyle.replace("foreground_light",  flDefault)
                strStyle = strStyle.replace("foreground_medium", fmDefault)
                strStyle = strStyle.replace("foreground_dark",   fdDefault)
                strStyle = strStyle.replace("selection_light",   slDefault)
                strStyle = strStyle.replace("selection_medium",  smDefault)
                strStyle = strStyle.replace("selection_dark",    sdDefault)
                strStyle = strStyle.replace("selection_item",    isDefault)

        return strStyle

    def haveNvidia(self):
        output = ""
        if sys.platform == "linux":
            output = self.run_command("lspci | grep NVIDIA")
        if not len(output.strip()):
            return False
        elif "command not found" in output:
            return False
        else:
            return True
        
    def win_command(self, cmd):
        logger.debug(cmd)
        with subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, creationflags=subprocess.CREATE_NEW_CONSOLE) as process:
            stdout, stderr = process.communicate()
            logger.debug(stdout.decode())
            if stderr:
                logger.debug(stderr.decode())
            return_code = process.returncode
            if return_code != 0:
                logger.error(f"Error occurred with return code {return_code}")

    def run_command(self, command, env=None, exit_on_error=False, silent=True):
        if env is None:
            env = ""
            env_dict = os.environ.copy()
            for key in env_dict:
                env += f'{key}={env_dict[key]}\n'

        stdout_r, stdout_w = os.pipe()
        stderr_r, stderr_w = os.pipe()
        pid = os.fork()

        if pid == 0:
            os.close(stdout_r)  
            os.close(stderr_r)
            os.dup2(stdout_w, 1)
            os.dup2(stderr_w, 2)
            os.close(stdout_w)
            os.close(stderr_w)
            os.execlp("bash", "bash", "-c", command, env)
        else:
            os.close(stdout_w)
            os.close(stderr_w)
            with os.fdopen(stdout_r) as stdout_pipe, os.fdopen(stderr_r) as stderr_pipe:
                output = stdout_pipe.read().strip()
                error_output = stderr_pipe.read().strip()
            pid, self.status = os.waitpid(pid, 0)

            if os.WIFEXITED(self.status) and os.WEXITSTATUS(self.status) != 0:
                if not silent:
                    print(f"Command failed: {command}", file=sys.stderr)
                    print(f"Error message: {error_output}", file=sys.stderr)
                if exit_on_error:
                    sys.exit(os.WEXITSTATUS(self.status))

            return output + error_output
        
def run():
    parser = argparse.ArgumentParser(description="Cayenue")
    parser.add_argument("-p", "--profile", type=str, default="gui")
    parser.add_argument("-c", "--clear", action="store_true")
    parser.add_argument("-i", "--icon", action="store_true")
    args = parser.parse_args()

    
    clear_settings = args.clear
    settings_profile = args.profile

    if args.icon:
        if sys.platform == "win32":
            filename = os.environ['HOMEPATH'] + "/.cache/Cayenue/logs/logs.txt"
        else:
            filename = os.environ['HOME'] + "/.cache/Cayenue/logs/logs.txt"
        logger.add(filename, rotation="1 MB")

        if sys.platform == "win32":
            icon = f'{os.path.split(__file__)[0]}\\resources\\Cayenue.ico'
            working_dir = f'{os.path.split(sys.executable)[0]}'
            if "conda" in working_dir:
                working_dir = f'{working_dir}\\Scripts'
            executable = f'{working_dir}\\Cayenue.exe'
            try:
                import winshell
                link_filepath = f'{Path(winshell.desktop())}\\Cayenue.lnk'
                with winshell.shortcut(link_filepath) as link:
                    link.path = executable
                    link.description = "Cayenue"
                    link.arguments = ""
                    link.icon_location = (icon, 0)
                    link.working_directory = working_dir

                logger.debug(f'Desktop icon created for executable {executable}')
            except Exception as ex:
                logger.debug(f'Error attempting to create desktop icon : {ex}')
        else:
            icon = f'{os.path.split(__file__)[0]}/resources/Cayenue.png'
            executable = f'{os.path.split(sys.executable)[0]}/Cayenue %U'

            contents = (f'[Desktop Entry]\n'
                        f'Version={VERSION}\n'
                        f'Name=Cayeue\n'
                        f'Comment=IP Camera Application\n'
                        f'Exec={executable}\n'
                        f'Terminal=false\n'
                        f'Icon={icon}\n'
                        f'StartupWMClass=python3\n'
                        f'Type=Application\n'
                        f'Categories=Utility\n')
            
            try:
                with open('/usr/share/applications/Cayenue.desktop', 'w') as f:
                    f.write(contents)
                #logger.debug("Desktop icon created successfully")
                print("Desktop Icon Created Successfully, look in the system Applications to find Onvif GUI\n")
            except Exception as ex:
                logger.error(f'Error attempting to create desktop icon : {ex}')

        sys.exit()

    app = QApplication(sys.argv)

    app.setStyle('Fusion')
    window = MainWindow(clear_settings=clear_settings, settings_profile=settings_profile)
    window.show()
    app.exec()

if __name__ == '__main__':
    run()
