#!/usr/bin/env python3
#
# Copyright (c) 2026 Stormux
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA  02110-1301 USA.
#

"""NVDA to Cthulhu bridge plugin."""

import asyncio
import locale
import logging
import os
import shutil
from collections import OrderedDict
import subprocess
import threading
import urllib.parse
from typing import Any, Callable

from gi.repository import GLib

try:
    import msgpack
except Exception:  # pragma: no cover - optional dependency
    msgpack = None

try:
    import tornado
    import tornado.httpserver
    import tornado.ioloop
    import tornado.web
    import tornado.websocket
    from tornado.platform.asyncio import AsyncIOMainLoop
except Exception:  # pragma: no cover - optional dependency
    tornado = None

from cthulhu.plugin import Plugin, cthulhu_hookimpl
from cthulhu import braille
from cthulhu import debug
from cthulhu import speech
from cthulhu import settings_manager

logger = logging.getLogger(__name__)

DEFAULT_PORT = 3457
TRANSLATE_COMMAND = ("trans", "-no-autocorrect", "-no-warn", "-brief")
TRANSLATE_TIMEOUT = 5.0
TRANSLATE_CACHE_MAX = 512


def _coerce_text(value):
    if value is None:
        return ''
    if isinstance(value, bytes):
        try:
            return value.decode('utf-8')
        except Exception:
            return value.decode('utf-8', errors='replace')
    return str(value)


if tornado is None:
    class Nvda2CthulhuSocket:  # pragma: no cover - optional dependency
        """Placeholder when tornado is not available."""
        pass
else:
    class Nvda2CthulhuSocket(tornado.websocket.WebSocketHandler):
        """WebSocket handler for NVDA to Cthulhu messages."""

        def initialize(self, plugin):
            self.plugin = plugin

        def check_origin(self, origin):
            return True

        def on_message(self, message):
            self.plugin.handle_message(message)


class Nvda2Cthulhu(Plugin):
    """Bridge server that accepts NVDA to Cthulhu messages."""

    def __init__(self):
        logger.info("NVDA to Cthulhu __init__() called")
        try:
            super().__init__()
            logger.info("NVDA to Cthulhu super().__init__() completed")
            self.settingsManager = settings_manager.getManager()
            self.interruptEnabled = True
            self.serverThread = None
            self.serverLock = threading.Lock()
            self.httpServer = None
            self.ioLoop = None
            self.asyncioLoop = None
            self.translationCache = OrderedDict()
            self.translationCacheLock = threading.Lock()
            logger.info("NVDA to Cthulhu __init__() completed successfully")
        except Exception as e:
            logger.error(f"NVDA to Cthulhu __init__() failed: {e}")
            raise

    @cthulhu_hookimpl
    def activate(self, plugin=None):
        logger.info(f"NVDA to Cthulhu activate() called with plugin={plugin}, self={self}")
        if plugin is not None and plugin is not self:
            logger.info(f"NVDA to Cthulhu activate() early return: plugin is not self")
            return

        logger.info("Activating NVDA to Cthulhu plugin")
        self.registerGestureByString(
            self.toggle_interrupt,
            "NVDA to Cthulhu interrupt mode",
            "kb:cthulhu+shift+n",
            learnModeEnabled=True
        )
        self.registerGestureByString(
            self.toggle_translation,
            "NVDA to Cthulhu translation",
            "kb:cthulhu+control+shift+t",
            learnModeEnabled=True,
            globalBinding=True
        )
        if not self._dependencies_available():
            self._present_message("NVDA to Cthulhu missing dependencies: python-msgpack and python-tornado")
            logger.warning("NVDA to Cthulhu dependencies missing: msgpack or tornado")
            return
        self.start_server()

    @cthulhu_hookimpl
    def deactivate(self, plugin=None):
        if plugin is not None and plugin is not self:
            return

        logger.info("Deactivating NVDA to Cthulhu plugin")
        self.stop_server()

    def start_server(self):
        with self.serverLock:
            if self.serverThread and self.serverThread.is_alive():
                return
            self.serverThread = threading.Thread(
                target=self._server_main,
                name="Nvda2CthulhuServer",
                daemon=True
            )
            self.serverThread.start()

    def stop_server(self):
        with self.serverLock:
            ioLoop = self.ioLoop
            httpServer = self.httpServer

        if ioLoop is None:
            return

        def _stop():
            if httpServer:
                httpServer.stop()
            ioLoop.stop()

        try:
            ioLoop.add_callback(_stop)
        except Exception as exc:
            logger.warning(f"NVDA to Cthulhu: failed to stop server cleanly: {exc}")

        if self.serverThread and self.serverThread.is_alive():
            try:
                self.serverThread.join(timeout=2.0)
            except Exception as exc:
                logger.warning(f"NVDA to Cthulhu: failed to join server thread: {exc}")

    def toggle_interrupt(self, script=None, inputEvent=None):
        self.interruptEnabled = not self.interruptEnabled
        mode = "interrupt" if self.interruptEnabled else "no interrupt"
        self._present_message(f"NVDA to Cthulhu {mode}")
        return True

    def toggle_translation(self, script=None, inputEvent=None):
        if not self.settingsManager and self.app:
            self.settingsManager = self.app.getSettingsManager()
        if not self.settingsManager:
            return False
        if not self._translation_command_available():
            self._present_message("NVDA to Cthulhu translation unavailable (missing translate-shell)")
            return True
        currentValue = bool(self.settingsManager.getSetting('nvda2cthulhuTranslateEnabled'))
        newValue = not currentValue
        self.settingsManager.setSetting('nvda2cthulhuTranslateEnabled', newValue)
        mode = "translation enabled" if newValue else "translation disabled"
        self._present_message(f"NVDA to Cthulhu {mode}")
        return True

    def handle_message(self, message):
        try:
            request = self._parse_request(message)
        except Exception as exc:
            logger.warning(f"NVDA to Cthulhu: failed to parse message: {exc}")
            debug.printException(debug.LEVEL_WARNING)
            return

        if not request:
            return

        try:
            requestType, payload = request
            if requestType == "SpeakText":
                self._handle_speak(payload)
            elif requestType == "BrailleText":
                self._schedule_on_main_thread(self._handle_braille, payload)
            elif requestType == "CancelSpeech":
                self._schedule_on_main_thread(self._handle_cancel_speech)
        except Exception as exc:
            logger.warning(f"NVDA to Cthulhu: failed to handle message: {exc}")
            debug.printException(debug.LEVEL_WARNING)

    def _server_main(self):
        if not self._dependencies_available():
            return
        address, port = self._get_bind_address()
        try:
            self.asyncioLoop = asyncio.new_event_loop()
            asyncio.set_event_loop(self.asyncioLoop)
            AsyncIOMainLoop().install()

            app = tornado.web.Application([
                (r"/", Nvda2CthulhuSocket, {"plugin": self}),
            ])

            self.httpServer = tornado.httpserver.HTTPServer(app)
            self.httpServer.listen(port, address=address)
            self.ioLoop = tornado.ioloop.IOLoop.current()
            logger.info(f"NVDA to Cthulhu listening on {address}:{port}")
            self.ioLoop.start()
        except Exception as exc:
            logger.error(f"NVDA to Cthulhu failed to start server: {exc}")
            self._schedule_on_main_thread(self._present_message, "NVDA to Cthulhu server failed to start")
        finally:
            self.httpServer = None
            if self.ioLoop:
                try:
                    self.ioLoop.stop()
                except Exception:
                    pass
            self.ioLoop = None
            self.asyncioLoop = None

    def _get_bind_address(self):
        port = DEFAULT_PORT
        host = os.environ.get("NVDA2SPEECHD_HOST")
        if host:
            try:
                parsed = urllib.parse.urlparse(host)
                if parsed.port:
                    port = parsed.port
            except Exception:
                logger.warning(f"NVDA to Cthulhu: invalid NVDA2SPEECHD_HOST: {host}")
        return "127.0.0.1", port

    def _parse_request(self, message):
        if not self._dependencies_available():
            return None
        if isinstance(message, str):
            return self._parse_payload(message)

        if not isinstance(message, (bytes, bytearray)):
            return None

        try:
            payload = msgpack.unpackb(message, raw=False)
        except Exception as exc:
            logger.warning(f"NVDA to Cthulhu: failed to decode msgpack: {exc}")
            return None

        return self._parse_payload(payload)

    def _parse_payload(self, payload):
        if isinstance(payload, str):
            if payload == "CancelSpeech":
                return "CancelSpeech", None
            return "SpeakText", payload

        if isinstance(payload, dict):
            if len(payload) != 1:
                return None
            key, value = next(iter(payload.items()))
            key = _coerce_text(key)
            if key == "SpeakText":
                return "SpeakText", _coerce_text(value)
            if key == "BrailleText":
                return "BrailleText", _coerce_text(value)
            if key == "CancelSpeech":
                return "CancelSpeech", None
            return None

        if isinstance(payload, (list, tuple)):
            if not payload:
                return None
            key = _coerce_text(payload[0])
            if key == "CancelSpeech":
                return "CancelSpeech", None
            if len(payload) < 2:
                return None
            value = _coerce_text(payload[1])
            if key == "SpeakText":
                return "SpeakText", value
            if key == "BrailleText":
                return "BrailleText", value
            return None

        return None

    def _handle_speak(self, text):
        if not text or not text.strip():
            return
        if self._translation_enabled():
            logger.info(f"NVDA to Cthulhu: translating text: {text[:50]}")
            translated = self._translate_text(text)
            logger.info(f"NVDA to Cthulhu: translated to: {translated[:50]}")
            text = translated
        self._schedule_on_main_thread(self._speak_text, text, self.interruptEnabled)

    def _speak_text(self, text: str, interrupt: bool) -> None:
        speech.speak(text, interrupt=interrupt)

    def _handle_cancel_speech(self) -> None:
        speech.stop()

    def _handle_braille(self, text):
        if not text or not text.strip():
            return
        if not self._braille_enabled():
            return
        duration = self._get_braille_flash_time()
        braille.displayMessage(text, flashTime=duration)

    def _braille_enabled(self):
        if not self.settingsManager:
            return False
        enableBraille = self.settingsManager.getSetting('enableBraille')
        enableMonitor = self.settingsManager.getSetting('enableBrailleMonitor')
        enableFlash = self.settingsManager.getSetting('enableFlashMessages')
        return bool((enableBraille or enableMonitor) and enableFlash)

    def _get_braille_flash_time(self):
        if not self.settingsManager:
            return 0
        if self.settingsManager.getSetting('flashIsPersistent'):
            return -1
        return self.settingsManager.getSetting('brailleFlashTime')

    def _present_message(self, message):
        try:
            scriptManager = self.app.getScriptManager()
            scriptManager.get_default_script().presentMessage(message, resetStyles=False)
        except Exception:
            logger.info(message)

    def _schedule_on_main_thread(self, callback: Callable[..., Any], *args: Any) -> bool:
        try:
            GLib.idle_add(self._run_on_main_thread, callback, args)
            return True
        except Exception as exc:
            logger.warning(f"NVDA to Cthulhu: failed to queue main-loop callback: {exc}")
            debug.printException(debug.LEVEL_WARNING)
            return False

    def _run_on_main_thread(self, callback: Callable[..., Any], args: tuple[Any, ...]) -> bool:
        try:
            callback(*args)
        except Exception as exc:
            logger.warning(f"NVDA to Cthulhu: main-loop callback failed: {exc}")
            debug.printException(debug.LEVEL_WARNING)
        return False

    def _dependencies_available(self):
        return msgpack is not None and tornado is not None

    def _translation_enabled(self):
        if not self.settingsManager:
            return False
        return bool(self.settingsManager.getSetting('nvda2cthulhuTranslateEnabled'))

    def _translation_command_available(self):
        return shutil.which(TRANSLATE_COMMAND[0]) is not None

    def _get_target_language(self):
        """Get the target language code from system locale."""
        try:
            # Get system locale (e.g., 'en_US.UTF-8')
            system_locale = locale.getdefaultlocale()[0]
            if system_locale:
                # Extract language code (e.g., 'en' from 'en_US')
                lang_code = system_locale.split('_')[0]
                return lang_code
        except Exception as exc:
            logger.warning(f"NVDA to Cthulhu: failed to get system locale: {exc}")

        # Fallback to environment variable
        try:
            lang_env = os.environ.get('LANG', '')
            if lang_env:
                # Extract language code from LANG (e.g., 'en' from 'en_US.UTF-8')
                lang_code = lang_env.split('_')[0]
                if lang_code:
                    return lang_code
        except Exception:
            pass

        return None

    def _get_translate_target(self):
        env_target = os.environ.get("NVDA2CTHULHU_TRANSLATE_TARGET")
        if env_target:
            return env_target
        return self._get_target_language()

    def _translate_text(self, text):
        cached = self._get_cached_translation(text)
        if cached is not None:
            logger.info(f"NVDA to Cthulhu: using cached translation")
            return cached
        if not self._translation_command_available():
            logger.warning("NVDA to Cthulhu translation failed: translate-shell not available")
            return text

        translate_target = self._get_translate_target()
        command = list(TRANSLATE_COMMAND)
        if translate_target:
            command.append(f":{translate_target}")

        logger.info(f"NVDA to Cthulhu: running trans command on: {text[:50]}")
        try:
            result = subprocess.run(
                command,
                input=text,
                text=True,
                encoding="utf-8",
                errors="replace",
                capture_output=True,
                check=False,
                timeout=TRANSLATE_TIMEOUT
            )
        except subprocess.TimeoutExpired:
            logger.warning("NVDA to Cthulhu translation failed: timed out")
            debug.printMessage(debug.LEVEL_INFO, "NVDA to Cthulhu translation failed: timed out", True)
            return text
        except Exception as exc:
            logger.warning(f"NVDA to Cthulhu translation failed: {exc}")
            debug.printMessage(debug.LEVEL_INFO, f"NVDA to Cthulhu translation failed: {exc}", True)
            return text

        if result.returncode != 0:
            stderr = result.stderr.strip()
            if stderr:
                logger.warning(f"NVDA to Cthulhu translation failed: {stderr}")
                debug.printMessage(debug.LEVEL_INFO, f"NVDA to Cthulhu translation failed: {stderr}", True)
            return text

        output = result.stdout.strip()
        logger.info(f"NVDA to Cthulhu: trans output: {output[:50]}")
        if not output:
            return text
        self._set_cached_translation(text, output)
        return output

    def _get_cached_translation(self, text):
        with self.translationCacheLock:
            cached = self.translationCache.get(text)
            if cached is None:
                return None
            self.translationCache.move_to_end(text)
            return cached

    def _set_cached_translation(self, text, translated):
        with self.translationCacheLock:
            if text in self.translationCache:
                self.translationCache.move_to_end(text)
            self.translationCache[text] = translated
            while len(self.translationCache) > TRANSLATE_CACHE_MAX:
                self.translationCache.popitem(last=False)
