# multicast_player_optimized.py
import time
import json
import socket
import threading
import struct
from time import sleep
from queue import Queue, Empty
from concurrent.futures import ThreadPoolExecutor, TimeoutError

from app import multicastConfig, log, redisObj
from app.app_config import *
from app.player import VLCController

# Constants:
PLAY_TIMEOUT = 2.0  # 原来 timeout(2.0)
REDIS_QUEUE_MAXSIZE = 1024
MULTICAST_RETRY_DELAY = 1.0  # socket 异常后重试间隔（秒）


class MulticastPlayer(object):
    def __init__(self, config=multicastConfig):
        # config
        self.network_caching = config.network_caching
        self.address = config.address  # list of dicts with keys id, ip, port, url, priority, volume, gpio...
        self.player_volume = PLAYER_VOLUME

        # runtime state
        self.priority = 0
        self.playing_id = 0
        self.current_uri = ""
        self.currentScreenName = ""

        # locks
        self.status_lock = threading.Lock()
        self.priority_lock = threading.Lock()
        self.multicast_volume_lock = threading.Lock()

        # available items map (id -> item dict)
        self.available_item = {}
        for item in self.address:
            # ensure expected keys exist and have defaults
            item.setdefault("update_status", 0)
            item.setdefault("available_status", 0)
            item.setdefault("counter", 0)
            item.setdefault("volume", item.get("volume", self.player_volume))
            item.setdefault("priority", item.get("priority", 0))
            self.available_item[item["id"]] = item

        # VLC controller (assume this object manages libvlc and has .start_vlc(), .play(url=), .set_volume(), .quit(), .running)
        self.player = VLCController(cmd=self._get_vlc_cmd(), on_state_change=self.vlc_callback)

        # Executor used only for play timeouts (single persistent thread, avoids thread-pool leaks)
        self.play_executor = ThreadPoolExecutor(max_workers=1)

        # Threads and control
        self.stop_event = threading.Event()
        self.threads = []  # list of threading.Thread running long-lived loops (multicast binds, monitor, redis parser, redis handler)

        # Redis subscription handling: one parser thread + one worker thread with queue
        self.redis_sub_volume = redisObj.subscribe_volume()
        self.redis_queue = Queue(maxsize=REDIS_QUEUE_MAXSIZE)

        # Start nothing in __init__. Call start() to run threads.
        log.logger.info("MulticastPlayer initialized")

    def _get_vlc_cmd(self):
        # Keep same arguments as original
        return [
            "/usr/sbin/vlc",
            "--control", "rc",
            "--rc-fake-tty",
            "--quiet",
            "--aout=alsa",
            "--gain-init=0.0",
            "--network-caching=%d" % self.network_caching,
            "--network-synchronisation",
            "--clock-synchro=1",
        ]

    # ---- VLC state callback ----
    def vlc_callback(self, state):
        # state mapping retained from original
        try:
            if state == 3:
                self.MediaPlayerPlaying_cb()
            elif state in (-1, 0, 5):
                self.MediaPlayerStopped_cb()
        except Exception as e:
            log.logger.exception("vlc_callback exception: %s", e)

    # ---- Redis message handling ----
    def _redis_parser_loop(self):
        """Continuously parse redis sub responses and enqueue the parsed JSON to redis_queue."""
        log.logger.info("Redis parser thread started")
        while not self.stop_event.is_set():
            try:
                msg = self.redis_sub_volume.parse_response()  # blocking
                # Expected original format: msg[2] contains JSON string
                if not msg or len(msg) < 3:
                    continue
                raw = msg[2]
                try:
                    data = json.loads(raw)
                except Exception:
                    log.logger.error("redis msg not json: %s", raw)
                    continue
                # enqueue (drop if full to avoid memory growth)
                try:
                    self.redis_queue.put_nowait(data)
                except Exception:
                    # queue full; drop oldest to make room (prevent unbounded memory growth)
                    try:
                        _ = self.redis_queue.get_nowait()
                        self.redis_queue.put_nowait(data)
                    except Exception:
                        log.logger.warning("redis queue full; dropped message")
            except Exception as e:
                # parse_response may raise on connection issues; if so, sleep briefly and retry
                log.logger.exception("Exception in redis parser loop: %s", e)
                time.sleep(0.5)
        log.logger.info("Redis parser thread exiting")

    def _redis_worker_loop(self):
        """Consume parsed redis messages and handle volume updates in a single worker thread."""
        log.logger.info("Redis worker thread started")
        while not self.stop_event.is_set():
            try:
                data = self.redis_queue.get(timeout=0.5)
            except Empty:
                continue
            try:
                self.handleRedisVolumeData(data)
            except Exception as e:
                log.logger.exception("Exception handling redis volume data: %s", e)
            finally:
                # mark task done if using task tracking (Queue.task_done not required)
                pass
        log.logger.info("Redis worker thread exiting")

    def handleRedisVolumeData(self, data):
        """Process a single redis message dict to update multicast volumes."""
        if not data:
            return
        # check multicast keys exist (original logic used "multicast_1_volume" as marker)
        if "multicast_1_volume" in data:
            # update available_item volumes in-place. Use lock only around mutations.
            for i in range(1, ADDRESS_COUNT + 1):
                key = f"multicast_{i}_volume"
                if key in data:
                    with self.status_lock:
                        if i in self.available_item:
                            self.available_item[i]["volume"] = data[key]
            # if something is playing, apply new volume
            if self.playing_id > 0:
                with self.multicast_volume_lock:
                    volume = self.available_item.get(self.playing_id, {}).get("volume", self.player_volume)
                    try:
                        self.player.set_volume(volume)
                        log.logger.info("Event set multicast player volume:%d", volume)
                    except Exception:
                        log.logger.exception("Failed to set volume to %s", volume)
        else:
            # ignore other messages per original behavior
            pass

    # ---- MediaPlayer event handlers ----
    def MediaPlayerPlaying_cb(self):
        try:
            # set initial volume
            with self.multicast_volume_lock:
                self.player.set_volume(self.player_volume)
            log.logger.info("MediaPlayerPlaying_cb - set multicast volume:%d", self.player_volume)

            # volume event
            redisObj.volumeEventPush(json.dumps({"name": "MULTICAST", "action": "on"}))

            # screen on
            time.sleep(2)
            name = f"Screen{self.playing_id}"
            self.currentScreenName = name
            redisObj.screen_publish(json.dumps({"action": "on", "name": name}))
            log.logger.info("Trigger %s to screen", name)

            # gpio
            gpio = self.available_item.get(self.playing_id, {}).get('gpio')
            if gpio and gpio != 'Disabled':
                redisObj.lpush(json.dumps({"id": "gpio135", "type": gpio}))

            # log push
            item = self.available_item.get(self.playing_id, {})
            redisObj.logpush("MULTICAST,START PLAY,Priority:%d <%s:%d>" % (self.playing_id, item.get("ip", ""), item.get("port", 0)))
        except Exception:
            log.logger.exception("MediaPlayerPlaying_cb exception")

    def MediaPlayerStopped_cb(self):
        try:
            log.logger.info("MediaPlayerStopped_cb")
            redisObj.volumeEventPush(json.dumps({"name": "MULTICAST", "action": "off"}))
            redisObj.logpush("MULTICAST,STOP PLAY,")
        except Exception:
            log.logger.exception("MediaPlayerStopped_cb exception")

    # ---- Play control (with safe timeout using persistent executor) ----
    def _play_task(self, uri):
        """Actual play invocation executed in play_executor worker."""
        try:
            if not self.player.running:
                # start vlc and play
                self.player.start_vlc()
                self.player.play(url=uri)
            else:
                # toggle off gpio & screen per original logic then play new uri
                try:
                    redisObj.lpush(json.dumps({"id": "gpio135", "type": "Off"}))
                    redisObj.screen_publish(json.dumps({"action": "off", "name": self.currentScreenName}))
                    log.logger.info("Trigger off to screen")
                except Exception:
                    log.logger.exception("Error sending off gpio/screen")
                self.player.play(url=uri)
                with self.multicast_volume_lock:
                    self.player.set_volume(self.player_volume)
        except Exception:
            log.logger.exception("_play_task exception")

    def play(self):
        """Public play method. Uses a persistent single-thread executor and a timeout to avoid blocking.
           Returns True if play completed within timeout, False if timed out."""
        if not self.current_uri:
            log.logger.warning("play called but current_uri empty")
            return False
        try:
            future = self.play_executor.submit(self._play_task, self.current_uri)
            # wait for completion up to PLAY_TIMEOUT seconds
            future.result(timeout=PLAY_TIMEOUT)
            return True
        except TimeoutError:
            log.logger.warning("play timed out after %.2fs", PLAY_TIMEOUT)
            # if timed out, try to quit player to recover (best-effort)
            try:
                self.player.quit()
            except Exception:
                log.logger.exception("Error quitting player after play timeout")
            return False
        except Exception:
            log.logger.exception("Exception in play()")
            return False

    # ---- Monitor thread ----
    def _monitor_channel_loop(self):
        """Monitor channel: update counters, adjust priorities and trigger multicast callbacks."""
        log.logger.info("Monitor thread started")
        last_priority = -1
        sleep_interval = 0.5
        while not self.stop_event.is_set():
            sleep(sleep_interval)
            # skip monitoring if priority == 100 (call event holds)
            if self.priority == 100:
                continue

            # log priority change at most when it actually changes
            if last_priority != self.priority:
                last_priority = self.priority
                log.logger.info("Monitor priority: %d", self.priority)

            # update counters and availability
            # minimize allocations by local referencing
            with self.status_lock:
                for item in self.available_item.values():
                    if item.get("update_status", 0) == 1:
                        item["update_status"] = 0
                        item["available_status"] = 1
                        item["counter"] = TIME_COUNTER
                    else:
                        # decrement but guard negative
                        c = item.get("counter", 0) - 1
                        item["counter"] = c
                        if c <= 0:
                            item["available_status"] = 0

            # if nothing playing, continue
            if self.playing_id == 0:
                continue

            # if current playing has expired, find next highest priority available
            current = self.available_item.get(self.playing_id)
            if current and current.get("counter", 0) == 0:
                tmp_id = 0
                tmp_priority = -1
                # find best available
                for per_item in self.available_item.values():
                    if per_item.get("available_status", 0) == 1:
                        p = per_item.get("priority", 0)
                        if p > tmp_priority:
                            tmp_priority = p
                            tmp_id = per_item.get("id", 0)
                if tmp_id != 0:
                    chosen = self.available_item[tmp_id]
                    self.current_uri = chosen["url"]
                    with self.priority_lock:
                        self.priority = chosen.get("priority", 0)
                    # trigger change
                    self.multicast_callback(tmp_id, 1)
                else:
                    # stop playing
                    self.multicast_callback(0, 0)

        log.logger.info("Monitor thread exiting")

    # ---- Multicast bind thread (one per address) ----
    def _multicast_bind_loop(self, item):
        """Listen to multicast address and update available_item on packets.
           Will recreate socket on recoverable errors with a small delay to avoid tight loop."""
        thread_name = f"multicast-{item.get('id')}"
        log.logger.info("id:%d Thread start successfully...", item.get("id"))
        while not self.stop_event.is_set():
            s = None
            try:
                # create UDP socket
                s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
                s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
                # On some systems binding to multicast group ip requires ''/INADDR_ANY
                bind_addr = (item["ip"], item["port"])
                try:
                    s.bind(bind_addr)
                except OSError:
                    # if cannot bind to group address, try binding to ('', port)
                    s.bind(('', item["port"]))
                # join multicast group
                # struct.pack('4s4s', inet_aton(group), inet_aton(iface))
                mreq = socket.inet_aton(item["ip"]) + socket.inet_aton(ANY)
                s.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
                s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 255)
                s.setblocking(True)

                # loop receiving
                while not self.stop_event.is_set():
                    try:
                        data, address = s.recvfrom(2048)
                        # mark update_status safely
                        with self.status_lock:
                            self.available_item[item["id"]]["update_status"] = 1
                        # if this address has higher priority, trigger play
                        if item.get("priority", 0) > self.priority:
                            with self.priority_lock:
                                self.priority = item.get("priority", 0)
                            self.current_uri = item.get("url", "")
                            self.multicast_callback(item["id"], 1)
                    except socket.timeout:
                        continue
                    except OSError as e:
                        # socket error -> break to recreate socket
                        log.logger.exception("Socket error on %s: %s", thread_name, e)
                        break
                    except Exception as e:
                        # unexpected exception, log and continue
                        log.logger.exception("Exception in multicast recv loop: %s", e)
                        break
            except Exception as e:
                log.logger.exception("Exception creating multicast socket for id %s: %s", item.get("id"), e)
            finally:
                # ensure socket closed
                try:
                    if s:
                        s.close()
                except Exception:
                    pass
            # if we reach here due to exception, sleep a bit before retrying to avoid tight restart loops
            if not self.stop_event.is_set():
                time.sleep(MULTICAST_RETRY_DELAY)
        log.logger.info("Multicast thread for id %s exiting", item.get("id"))

    # ---- Multicast callback (triggered by recv / monitor) ----
    def multicast_callback(self, id, action):
        """action:
            1 - start play this id
            2 - stop play for Call Event (pause/hold)
            3 - resume play for Call Event
            other - stop playing entirely
        """
        try:
            if action == 1:
                log.logger.info("Change url:%s", self.available_item[id]["url"])
                log.logger.info("Start play address id:%d", id)
                # set volume and playing id, then call play()
                self.player_volume = self.available_item[id].get("volume", self.player_volume)
                self.playing_id = id
                # update current uri
                self.current_uri = self.available_item[id].get("url", self.current_uri)
                # call play (non-blocking/with timeout)
                self.play()
            elif action == 2:
                # set priority high to prevent switches
                with self.priority_lock:
                    self.priority = 100
                log.logger.info("Stop play address id:%d for Call Event", self.playing_id)
            elif action == 3:
                # resume: reset priority to 0 (after short wait)
                if self.priority != 0:
                    sleep(1)
                    with self.priority_lock:
                        self.priority = 0
                log.logger.info("resume play address id:%d for Call Event", self.playing_id)
            else:
                # full stop
                try:
                    self.player.quit()
                except Exception:
                    log.logger.exception("Error quitting player on stop")
                try:
                    redisObj.lpush(json.dumps({"id": "gpio135", "type": "Off"}))
                    redisObj.screen_publish(json.dumps({"action": "off", "name": self.currentScreenName}))
                    log.logger.info("Trigger off to screen")
                except Exception:
                    log.logger.exception("Error publishing off gpio/screen")
                log.logger.info("Stop play address id:%d", self.playing_id)
                self.playing_id = 0
                with self.priority_lock:
                    self.priority = 0
        except Exception:
            log.logger.exception("Exception in multicast_callback")

    # ---- Lifecycle management ----
    def start(self):
        """Start all background threads: multicast listeners, monitor, redis parser+worker."""
        log.logger.info("Start MulticastPlayer threads")
        # multicast threads (one per address)
        for item in self.address:
            t = threading.Thread(target=self._multicast_bind_loop, args=(item,), daemon=True)
            t.start()
            self.threads.append(t)

        # monitor thread
        mon_t = threading.Thread(target=self._monitor_channel_loop, daemon=True)
        mon_t.start()
        self.threads.append(mon_t)

        # redis parser and worker
        parser_t = threading.Thread(target=self._redis_parser_loop, daemon=True)
        worker_t = threading.Thread(target=self._redis_worker_loop, daemon=True)
        parser_t.start(); worker_t.start()
        self.threads.extend([parser_t, worker_t])

        log.logger.info("MulticastPlayer started with %d threads", len(self.threads))

    def stop(self, wait=True, timeout=5.0):
        """Signal threads to stop, shutdown executors and player. Optionally wait for threads to finish."""
        log.logger.info("Stopping MulticastPlayer...")
        self.stop_event.set()

        # close redis subscription if API allows (best-effort)
        try:
            if hasattr(self.redis_sub_volume, "close"):
                try:
                    self.redis_sub_volume.close()
                except Exception:
                    pass
        except Exception:
            pass

        # attempt to quit player
        try:
            self.player.quit()
        except Exception:
            log.logger.exception("Error quitting player on stop")

        # shutdown play executor
        try:
            self.play_executor.shutdown(wait=False)
        except Exception:
            log.logger.exception("Error shutting down play_executor")

        # wait for threads to finish (best-effort)
        if wait:
            end = time.time() + timeout
            for t in self.threads:
                remaining = max(0.0, end - time.time())
                if remaining <= 0:
                    break
                try:
                    t.join(timeout=remaining)
                except Exception:
                    pass
        log.logger.info("MulticastPlayer stopped")

    # ---- signal handler wrapper for integration with external signal handling ----
    def singal_handler(self, signum=None, frame=None):
        # original behavior called sys.exit(0) after cleanup. We will attempt graceful stop then exit.
        log.logger.info("Signal received, shutting down...")
        try:
            self.stop(wait=True, timeout=5.0)
        finally:
            # explicit exit as original did
            try:
                sys.exit(0)
            except SystemExit:
                raise
            except Exception:
                pass

    # destructor fallback
    def __del__(self):
        try:
            self.stop(wait=False)
        except Exception:
            pass