import sys
import os
import json
import time
import socket
import threading
import subprocess
import ctypes
from datetime import datetime

# --- Dependency Management ---
def install_dependencies():
    dependencies = ["PyQt6", "pyserial", "cryptography"]
    for package in dependencies:
        try:
            if package == "PyQt6":
                import PyQt6
            elif package == "pyserial":
                import serial
            elif package == "cryptography":
                import cryptography
        except ImportError:
            print(f"Installing {package}...")
            creation_flags = 0
            if sys.platform == "win32":
                creation_flags = subprocess.CREATE_NO_WINDOW
            subprocess.check_call([sys.executable, "-m", "pip", "install", package], creationflags=creation_flags)

def hide_console():
    """Hides the console window on Windows."""
    if sys.platform == "win32":
        kernel32 = ctypes.WinDLL('kernel32')
        user32 = ctypes.WinDLL('user32')
        hWnd = kernel32.GetConsoleWindow()
        if hWnd != 0:
            user32.ShowWindow(hWnd, 0) # 0 = SW_HIDE

# --- Setup Imports (Post Dependency Check) ---
hide_console() # Hide as early as possible
install_dependencies()

try:
    import winreg
except ImportError:
    winreg = None

from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
                             QLabel, QLineEdit, QPushButton, QTextEdit, QComboBox,
                             QFormLayout, QSystemTrayIcon, QMenu, QCheckBox)
from PyQt6.QtCore import pyqtSignal, QTimer, Qt
from PyQt6.QtGui import QIcon, QPixmap, QColor
import serial
import serial.tools.list_ports
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import base64

# --- Constants ---
PSK = b"GuardPatrolSecureReportingKey_32"
IV_LENGTH = 12
CONFIG_FILE = "config.json"

class BaseStationReceiver(QMainWindow):
    signal_received = pyqtSignal(dict)
    log_signal = pyqtSignal(str)

    def __init__(self):
        super().__init__()
        self.setWindowTitle("Guard Patrol - Central Station Receiver")
        self.setMinimumSize(800, 600)

        self.last_sequences = {}
        self.is_running = False
        self.server_socket = None
        self.ser = None

        self.init_ui()
        self.setup_tray()
        self.load_config()

        self.heartbeat_timer = QTimer(self)
        self.heartbeat_timer.timeout.connect(self.send_heartbeat)
        self.heartbeat_timer.start(60 * 60 * 1000)

        if self.auto_start_recv_check.isChecked():
            QTimer.singleShot(1000, self.start_receiver)

    def init_ui(self):
        central_widget = QWidget()
        self.setCentralWidget(central_widget)
        layout = QVBoxLayout(central_widget)

        settings_layout = QHBoxLayout()

        net_box = QFormLayout()
        self.port_edit = QLineEdit("9999")
        net_box.addRow("Listen Port:", self.port_edit)

        self.autostart_check = QCheckBox("Start with Windows")
        if winreg:
            self.autostart_check.setChecked(self.check_autostart())
            self.autostart_check.stateChanged.connect(self.toggle_autostart)
        else:
            self.autostart_check.setEnabled(False)
        net_box.addRow(self.autostart_check)

        self.auto_start_recv_check = QCheckBox("Start Receiver on Launch")
        net_box.addRow(self.auto_start_recv_check)
        settings_layout.addLayout(net_box)

        com_box = QFormLayout()
        self.com_combo = QComboBox()
        self.refresh_ports()
        self.forward_serial_check = QCheckBox("Forward to Serial")
        self.forward_serial_check.setChecked(True)
        com_box.addRow(self.forward_serial_check, self.com_combo)

        self.baud_combo = QComboBox()
        self.baud_combo.addItems(["9600", "19200", "38400", "57600", "115200"])
        self.baud_combo.setCurrentText("9600")
        com_box.addRow("Baud Rate:", self.baud_combo)

        self.forward_ip_check = QCheckBox("Forward to IP (Monitoring SW)")
        self.forward_ip_check.setChecked(True)
        self.forward_ip_edit = QLineEdit("127.0.0.1")
        self.forward_port_edit = QLineEdit("10001")

        ip_layout = QHBoxLayout()
        ip_layout.addWidget(self.forward_ip_edit)
        ip_layout.addWidget(self.forward_port_edit)
        com_box.addRow(self.forward_ip_check, ip_layout)

        settings_layout.addLayout(com_box)

        self.start_btn = QPushButton("Start Receiver")
        self.start_btn.clicked.connect(self.toggle_receiver)
        settings_layout.addWidget(self.start_btn)

        layout.addLayout(settings_layout)

        layout.addWidget(QLabel("Received Signals:"))
        self.log_display = QTextEdit()
        self.log_display.setReadOnly(True)
        layout.addWidget(self.log_display)

        self.signal_received.connect(self.handle_signal)
        self.log_signal.connect(self.append_log)

    def append_log(self, text):
        ts = datetime.now().strftime("%H:%M:%S")
        self.log_display.append(f"[{ts}] {text}")

    def load_config(self):
        if not os.path.exists(CONFIG_FILE): return
        try:
            with open(CONFIG_FILE, 'r') as f:
                config = json.load(f)
                self.port_edit.setText(config.get("port", "9999"))
                self.auto_start_recv_check.setChecked(config.get("auto_start_receiver", False))
                self.baud_combo.setCurrentText(config.get("baud", "9600"))
                self.forward_ip_edit.setText(config.get("forward_ip", "127.0.0.1"))
                self.forward_port_edit.setText(config.get("forward_port", "10001"))
                self.forward_ip_check.setChecked(config.get("forward_ip_enabled", True))
                self.forward_serial_check.setChecked(config.get("forward_serial_enabled", True))
                com = config.get("com_port")
                if com:
                    index = self.com_combo.findText(com)
                    if index >= 0: self.com_combo.setCurrentIndex(index)
        except Exception as e:
            print("Error loading config:", e)

    def save_config(self):
        config = {
            "port": self.port_edit.text(),
            "com_port": self.com_combo.currentText(),
            "baud": self.baud_combo.currentText(),
            "auto_start_receiver": self.auto_start_recv_check.isChecked(),
            "forward_ip": self.forward_ip_edit.text(),
            "forward_port": self.forward_port_edit.text(),
            "forward_ip_enabled": self.forward_ip_check.isChecked(),
            "forward_serial_enabled": self.forward_serial_check.isChecked()
        }
        try:
            with open(CONFIG_FILE, 'w') as f:
                json.dump(config, f)
        except Exception as e:
            print("Error saving config:", e)

    def check_autostart(self):
        if not winreg: return False
        try:
            key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Run", 0, winreg.KEY_READ)
            val, _ = winreg.QueryValueEx(key, "GuardPatrolReceiver")
            winreg.CloseKey(key)
            # Check if it points to the current script
            return os.path.abspath(sys.argv[0]) in val
        except:
            return False

    def toggle_autostart(self, state):
        if not winreg: return
        enabled = (state == 2)
        key_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
        app_name = "GuardPatrolReceiver"

        # Point to the current script (which acts as the Watchdog)
        app_path = f'"{sys.executable}" "{os.path.abspath(sys.argv[0])}"'

        try:
            key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE)
            if enabled:
                winreg.SetValueEx(key, app_name, 0, winreg.REG_SZ, app_path)
                self.log("Auto-start (Watchdog) enabled")
            else:
                try:
                    winreg.DeleteValue(key, app_name)
                    self.log("Auto-start disabled")
                except FileNotFoundError: pass
            winreg.CloseKey(key)
        except Exception as e:
            self.log(f"Auto-start error: {e}")

    def refresh_ports(self):
        self.com_combo.clear()
        ports = serial.tools.list_ports.comports()
        for p in ports:
            self.com_combo.addItem(p.device)

    def setup_tray(self):
        self.tray_icon = QSystemTrayIcon(self)
        pixmap = QPixmap(32, 32)
        pixmap.fill(Qt.GlobalColor.transparent)
        from PyQt6.QtGui import QPainter
        painter = QPainter(pixmap)
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
        painter.setBrush(QColor("green"))
        painter.drawEllipse(4, 4, 24, 24)
        painter.end()
        self.tray_icon.setIcon(QIcon(pixmap))

        tray_menu = QMenu()
        show_action = tray_menu.addAction("Show")
        show_action.triggered.connect(self.show)
        exit_action = tray_menu.addAction("Exit")
        exit_action.triggered.connect(self.close_app)

        self.tray_icon.setContextMenu(tray_menu)
        self.tray_icon.show()

    def closeEvent(self, event):
        if self.tray_icon.isVisible():
            self.hide()
            event.ignore()
        else:
            self.close_app()

    def close_app(self):
        self.save_config()
        self.is_running = False
        if self.server_socket:
            self.server_socket.close()
        if self.ser and self.ser.is_open:
            self.ser.close()
        # Exit with 0 to tell the Watchdog not to restart us
        QApplication.quit()
        sys.exit(0)

    def toggle_receiver(self):
        if not self.is_running:
            self.start_receiver()
        else:
            self.stop_receiver()

    def start_receiver(self):
        self.save_config()
        port_str = self.port_edit.text()
        if not port_str.isdigit():
            self.log("Invalid Port")
            return
        port = int(port_str)
        com_port = self.com_combo.currentText()
        baud = int(self.baud_combo.currentText())

        try:
            if com_port:
                self.ser = serial.Serial(com_port, baud, timeout=1, write_timeout=2)

            self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            self.server_socket.bind(('0.0.0.0', port))
            self.server_socket.listen(5)

            self.is_running = True
            self.start_btn.setText("Stop Receiver")
            self.log(f"Receiver started on port {port}")

            threading.Thread(target=self.accept_connections, daemon=True).start()
        except Exception as e:
            self.log("Error starting receiver: " + str(e))

    def stop_receiver(self):
        self.is_running = False
        if self.server_socket:
            self.server_socket.close()
        self.start_btn.setText("Start Receiver")
        self.log("Receiver stopped.")

    def accept_connections(self):
        while self.is_running:
            try:
                client, addr = self.server_socket.accept()
                threading.Thread(target=self.handle_client, args=(client,), daemon=True).start()
            except:
                pass

    def handle_client(self, client):
        try:
            # Robustly read 2-byte length header
            header = b''
            while len(header) < 2:
                chunk = client.recv(2 - len(header))
                if not chunk: return
                header += chunk

            data_len = int.from_bytes(header, byteorder='big')

            chunks = []
            bytes_recvd = 0
            while bytes_recvd < data_len:
                chunk = client.recv(min(data_len - bytes_recvd, 4096))
                if not chunk: break
                chunks.append(chunk)
                bytes_recvd += len(chunk)

            if bytes_recvd < data_len:
                self.log(f"Incomplete payload: {bytes_recvd}/{data_len}")
                return

            encrypted_payload = b"".join(chunks).decode('utf-8')
            decrypted = self.decrypt(encrypted_payload)
            signal = json.loads(decrypted)

            acct = signal.get("acct")
            partition = signal.get("partition", 0)
            seq = signal.get("sequence", 0)
            ts = signal.get("timestamp", 0)

            key = (acct, partition)
            last_seq = self.last_sequences.get(key, 0)

            now = time.time() * 1000
            # Allow for slight clock drift (30s) but prevent old replays
            if seq <= last_seq or abs(now - ts) > 10 * 60 * 1000:
                self.log(f"REPLAY/TIME REJECTED: Acct {acct}, Seq {seq}")
                client.close()
                return

            self.last_sequences[key] = seq
            self.signal_received.emit(signal)

            ack_payload = json.dumps({"sequence": seq, "status": "ACK"})
            encrypted_ack = self.encrypt(ack_payload)
            ack_bytes = encrypted_ack.encode('utf-8')
            client.send(len(ack_bytes).to_bytes(2, byteorder='big'))
            client.send(ack_bytes)

        except Exception as e:
            self.log(f"Client processing error: {e}")
        finally:
            client.close()

    def decrypt(self, data):
        decoded = base64.b64decode(data)
        iv = decoded[:IV_LENGTH]
        ciphertext = decoded[IV_LENGTH:]
        aesgcm = AESGCM(PSK)
        return aesgcm.decrypt(iv, ciphertext, None).decode('utf-8')

    def encrypt(self, data):
        iv = bytes(range(IV_LENGTH))
        aesgcm = AESGCM(PSK)
        ciphertext = aesgcm.encrypt(iv, data.encode('utf-8'), None)
        return base64.b64encode(iv + ciphertext).decode('utf-8')

    def handle_signal(self, signal):
        acct = signal.get("acct")
        event = signal.get("event")
        qualifier = signal.get("qualifier", 1)
        partition = signal.get("partition")
        zone = signal.get("zone")
        note = signal.get("note", "")

        cid_str = f"[{acct} 18 {qualifier} {event:03d} {partition:02d} {zone:03d}]"
        self.log(f"SIGNAL: {cid_str} - {note}")

        # Offload all forwarding to background threads to keep UI responsive
        threading.Thread(target=self.perform_forwarding, args=(cid_str, note), daemon=True).start()

    def perform_forwarding(self, cid_str, note):
        # 1. Forward to Serial
        if self.forward_serial_check.isChecked() and self.ser and self.ser.is_open:
            try:
                self.ser.write(cid_str.encode('ascii', errors='replace') + b'\r\n')
                if note:
                    self.ser.write(f"TEXT: {note}\r\n".encode('ascii', errors='replace'))
            except Exception as e:
                self.log(f"Serial Forward Error: {e}")

        # 2. Forward to IP (Monitoring Simulator)
        if self.forward_ip_check.isChecked():
            self.forward_to_monitoring_sw(cid_str, note)

    def forward_to_monitoring_sw(self, cid_str, note):
        ip = self.forward_ip_edit.text()
        port_str = self.forward_port_edit.text()
        if not port_str.isdigit(): return
        port = int(port_str)

        try:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                s.settimeout(3)
                s.connect((ip, port))

                payload = cid_str
                if note:
                    payload += f" | NOTE: {note}"

                s.sendall(payload.encode('utf-8') + b'\r\n')

                # Wait for ACK from Monitoring SW
                ack = s.recv(1024)
                if b"ACK" in ack:
                    self.log(f"Forwarded to IP {ip}:{port} (ACK Received)")
                else:
                    self.log(f"Forwarded to IP {ip}:{port} (No ACK)")
        except Exception as e:
            self.log(f"IP Forward Error ({ip}:{port}): {e}")

    def send_heartbeat(self):
        if not self.is_running: return
        self.log("Sending Heartbeat (Account 0000)")
        hb = "[0000 18 1 602 00 000]\r\n"
        if self.ser and self.ser.is_open:
            self.ser.write(hb.encode('ascii'))

    def log(self, msg):
        self.log_signal.emit(msg)

# --- Watchdog Logic ---
def run_watchdog():
    """Restarts the UI process if it crashes."""
    script_path = os.path.abspath(sys.argv[0])

    # Try to find pythonw.exe for a cleaner launch without any console flash
    pythonw = sys.executable.replace("python.exe", "pythonw.exe")
    executable = pythonw if os.path.exists(pythonw) else sys.executable

    while True:
        # Use CREATE_NO_WINDOW to ensure no console icon appears in taskbar for the child
        creation_flags = 0
        if sys.platform == "win32":
            import subprocess
            creation_flags = subprocess.CREATE_NO_WINDOW

        process = subprocess.run([executable, script_path, "--ui-mode"], creationflags=creation_flags)

        # Exit if UI closed via "Exit" button (exit code 0)
        if process.returncode == 0:
            print("Watchdog: App exited normally.")
            break
        else:
            print(f"Watchdog: App crashed (code {process.returncode}). Restarting in 5s...")
            time.sleep(5)

if __name__ == "__main__":
    if "--ui-mode" in sys.argv:
        app = QApplication(sys.argv)
        window = BaseStationReceiver()
        window.show()
        sys.exit(app.exec())
    else:
        run_watchdog()
