#!/usr/bin/env python3
"""Sanad — unified robot assistant entry point.

Starts all subsystems and the FastAPI dashboard.

    python main.py               # default port 8000
    python main.py --port 8080   # custom port
"""

from __future__ import annotations

import argparse
import importlib
import os
import sys
import types
from pathlib import Path

# ─────────────────────────────────────────────────────────────────────────────
# Layout detection — support BOTH:
#   1. Dev layout:      <root>/Project/Sanad/main.py   (imports use Project.Sanad.*)
#   2. Deployed layout: /home/unitree/Sanad/main.py    (no Project/ wrapper)
#
# In the deployed case we synthesize a `Project` namespace package and alias
# `Project.Sanad` → the local `Sanad` package, so every `from Project.Sanad.X
# import Y` keeps working without rewriting any other file.
# ─────────────────────────────────────────────────────────────────────────────
_THIS_DIR = Path(__file__).resolve().parent           # .../Sanad
_PARENT = _THIS_DIR.parent                            # .../Project   OR   /home/unitree

if _PARENT.name == "Project":
    # Dev layout — add the directory containing Project/
    _ROOT = _PARENT.parent
    if str(_ROOT) not in sys.path:
        sys.path.insert(0, str(_ROOT))
else:
    # Deployed layout — create a virtual Project package and alias
    if str(_PARENT) not in sys.path:
        sys.path.insert(0, str(_PARENT))
    if "Project" not in sys.modules:
        _proj = types.ModuleType("Project")
        _proj.__path__ = []  # mark as namespace package
        sys.modules["Project"] = _proj
    if "Project.Sanad" not in sys.modules:
        # Import the local Sanad package as a top-level module first
        _sanad = importlib.import_module(_THIS_DIR.name)
        sys.modules["Project.Sanad"] = _sanad
        sys.modules["Project"].Sanad = _sanad  # type: ignore[attr-defined]

# When main.py runs as a script (`python3 main.py`), Python loads it as the
# `__main__` module — NOT as `Project.Sanad.main`. Route handlers later do
# `from Project.Sanad.main import arm` etc; without the alias below, Python
# would re-execute this file from scratch under a different module name,
# creating a SECOND set of subsystem instances (uninitialised). Every
# `subsystem not available` / `No LowState` symptom traces back to this.
# The alias ensures both names point at the exact same module object.
if __name__ == "__main__":
    sys.modules["Project.Sanad.main"] = sys.modules["__main__"]

# asyncio compat shim — backfills asyncio.to_thread for Python 3.8.
# MUST be imported before any other Sanad module that uses asyncio.to_thread.
from Project.Sanad.core import asyncio_compat  # noqa: F401

from Project.Sanad.config import (
    DASHBOARD_HOST,
    DASHBOARD_PORT,
    DASHBOARD_INTERFACE,
    DDS_NETWORK_INTERFACE,
)
from Project.Sanad.core.logger import get_logger

log = get_logger("main")


def _safe_import(label: str, importer):
    """Import a module by callable, returning None if it fails."""
    try:
        return importer()
    except Exception:
        log.exception("Failed to import %s — that subsystem will be unavailable", label)
        return None


def _safe_construct(name: str, factory):
    """Construct a subsystem, log + return None on failure."""
    if factory is None:
        return None
    try:
        return factory()
    except Exception:
        log.exception("Failed to construct %s — that subsystem will be unavailable", name)
        return None


# ── isolated imports — one bad module never blocks the others ──
Brain                = _safe_import("Brain",                lambda: __import__("Project.Sanad.core.brain", fromlist=["Brain"]).Brain)
ArmController        = _safe_import("ArmController",        lambda: __import__("Project.Sanad.motion.arm_controller", fromlist=["ArmController"]).ArmController)
MacroPlayer          = _safe_import("MacroPlayer",          lambda: __import__("Project.Sanad.motion.macro_player", fromlist=["MacroPlayer"]).MacroPlayer)
MacroRecorder        = _safe_import("MacroRecorder",        lambda: __import__("Project.Sanad.motion.macro_recorder", fromlist=["MacroRecorder"]).MacroRecorder)
TeachingSession      = _safe_import("TeachingSession",      lambda: __import__("Project.Sanad.motion.teaching", fromlist=["TeachingSession"]).TeachingSession)
AudioManager         = _safe_import("AudioManager",         lambda: __import__("Project.Sanad.voice.audio_manager", fromlist=["AudioManager"]).AudioManager)
LocalTTSEngine       = _safe_import("LocalTTSEngine",       lambda: __import__("Project.Sanad.voice.local_tts", fromlist=["LocalTTSEngine"]).LocalTTSEngine)
WakePhraseManager    = _safe_import("WakePhraseManager",    lambda: __import__("Project.Sanad.voice.wake_phrase_manager", fromlist=["WakePhraseManager"]).WakePhraseManager)
LiveVoiceLoop        = _safe_import("LiveVoiceLoop",        lambda: __import__("Project.Sanad.voice.live_voice_loop", fromlist=["LiveVoiceLoop"]).LiveVoiceLoop)
TypedReplayEngine    = _safe_import("TypedReplayEngine",    lambda: __import__("Project.Sanad.voice.typed_replay", fromlist=["TypedReplayEngine"]).TypedReplayEngine)
GeminiVoiceClient    = _safe_import("GeminiVoiceClient",    lambda: __import__("Project.Sanad.gemini.client", fromlist=["GeminiVoiceClient"]).GeminiVoiceClient)
GeminiSubprocess     = _safe_import("GeminiSubprocess",     lambda: __import__("Project.Sanad.gemini.subprocess", fromlist=["GeminiSubprocess"]).GeminiSubprocess)
LocalSubprocess      = _safe_import("LocalSubprocess",      lambda: __import__("Project.Sanad.local.subprocess",  fromlist=["LocalSubprocess"]).LocalSubprocess)


# ── global instances (imported by route modules) ──

brain         = _safe_construct("brain",        Brain) if Brain else None
arm           = _safe_construct("arm",          ArmController)
audio_mgr     = _safe_construct("audio_mgr",    AudioManager)
voice_client  = _safe_construct("voice_client", GeminiVoiceClient)
local_tts     = _safe_construct("local_tts",    LocalTTSEngine)
wake_mgr      = _safe_construct("wake_mgr",     WakePhraseManager)
macro_rec     = _safe_construct("macro_rec",    (lambda: MacroRecorder(arm)) if (MacroRecorder and arm) else None)
macro_play    = _safe_construct("macro_play",   (lambda: MacroPlayer(audio_mgr, arm)) if (MacroPlayer and arm) else None)
teacher       = _safe_construct("teacher",      (lambda: TeachingSession(arm)) if (TeachingSession and arm) else None)
live_voice    = _safe_construct("live_voice",   (lambda: LiveVoiceLoop(voice_client, arm, wake_mgr, audio_mgr)) if (LiveVoiceLoop and voice_client and arm and wake_mgr and audio_mgr) else None)
# Which voice supervisor to mount. SANAD_VOICE_BRAIN chooses the brain
# that runs INSIDE the subprocess (see voice/sanad_voice.py); the same
# env var picks WHICH supervisor here manages that subprocess so its
# log-line parser matches the brain's emit format.
_brain_choice = os.environ.get("SANAD_VOICE_BRAIN", "gemini").strip().lower()
if _brain_choice == "local" and LocalSubprocess is not None:
    live_sub  = _safe_construct("live_sub", LocalSubprocess)
else:
    live_sub  = _safe_construct("live_sub", GeminiSubprocess)
typed_replay  = _safe_construct("typed_replay", (lambda: TypedReplayEngine(voice_client, audio_mgr)) if (TypedReplayEngine and voice_client and audio_mgr) else None)

# Wire everything into the Brain (only what was constructed)
def _safe_attach(method_name: str, value):
    if brain is None or value is None:
        return
    method = getattr(brain, method_name, None)
    if method is None:
        return
    try:
        method(value)
    except Exception:
        log.exception("brain.%s failed", method_name)


_safe_attach("attach_voice",         voice_client)
_safe_attach("attach_audio_manager", audio_mgr)
_safe_attach("attach_arm",           arm)
_safe_attach("attach_macro_recorder", macro_rec)
_safe_attach("attach_macro_player",  macro_play)
_safe_attach("attach_live_voice",    live_voice)


# ── Runtime sanity report ────────────────────────────────────────────────
SUBSYSTEMS = {
    "brain":         brain,
    "arm":           arm,
    "audio_mgr":     audio_mgr,
    "voice_client":  voice_client,
    "local_tts":     local_tts,
    "macro_rec":     macro_rec,
    "macro_play":    macro_play,
    "teacher":       teacher,
    "wake_mgr":      wake_mgr,
    "live_voice":    live_voice,
    "live_sub":      live_sub,
    "typed_replay":  typed_replay,
}

# Critical subsystems — if any of these are None, log a warning at startup
CRITICAL_SUBSYSTEMS = ("brain",)

for _name in CRITICAL_SUBSYSTEMS:
    if SUBSYSTEMS.get(_name) is None:
        log.error("CRITICAL subsystem '%s' is None — application will be unusable", _name)

_available = [k for k, v in SUBSYSTEMS.items() if v is not None]
_missing = [k for k, v in SUBSYSTEMS.items() if v is None]
log.info("Subsystems available (%d): %s", len(_available), ", ".join(_available))
if _missing:
    log.warning("Subsystems unavailable (%d): %s", len(_missing), ", ".join(_missing))


_already_shut_down = False


def _do_shutdown(from_signal: bool = False):
    """Clean shutdown — release hardware, stop background tasks. Idempotent."""
    global _already_shut_down
    if _already_shut_down:
        return
    _already_shut_down = True
    log.info("Shutdown requested")

    if arm is not None:
        try:
            if hasattr(arm, "cancel"):
                arm.cancel()
        except Exception:
            log.exception("arm.cancel() failed")
        try:
            if hasattr(arm, "disable"):
                arm.disable()
        except Exception:
            log.exception("arm.disable() failed")

    if live_sub is not None:
        try:
            running = live_sub.is_running() if callable(getattr(live_sub, "is_running", None)) else False
            if running:
                live_sub.stop()
        except Exception:
            log.exception("live_sub.stop() failed")

    if audio_mgr is not None:
        try:
            if hasattr(audio_mgr, "close"):
                audio_mgr.close()
        except Exception:
            log.exception("audio_mgr.close() failed")

    log.info("Shutdown complete")


import atexit  # noqa: E402
atexit.register(_do_shutdown)
# NOTE: Do NOT install custom SIGINT/SIGTERM handlers here.
# Uvicorn installs its own signal handlers for graceful shutdown.
# If we override them, Ctrl+C never reaches uvicorn and the server
# keeps running forever.  Our _do_shutdown runs via atexit instead.


def _print_env_diagnostic():
    """Print everything you'd need to debug a deployment issue."""
    print("=" * 60)
    print("SANAD ENVIRONMENT DIAGNOSTIC")
    print("=" * 60)
    print(f"Python:        {sys.version}")
    print(f"Executable:    {sys.executable}")
    print(f"Platform:      {sys.platform}")
    print(f"BASE_DIR:      {_THIS_DIR}")
    print(f"Parent:        {_PARENT}")
    print(f"Layout:        {'dev (Project/Sanad)' if _PARENT.name == 'Project' else 'deployed (top-level Sanad)'}")
    print(f"Dashboard:     {DASHBOARD_HOST}:{DASHBOARD_PORT} (interface: {DASHBOARD_INTERFACE})")
    print(f"DDS interface: {DDS_NETWORK_INTERFACE}")
    print()
    print("sys.path[0:8]:")
    for p in sys.path[:8]:
        print(f"  {p}")
    print()
    print("Critical imports:")
    for mod_name in ("uvicorn", "fastapi", "pydantic", "starlette",
                     "websockets", "httpx", "pyaudio", "pyrealsense2",
                     "unitree_sdk2py", "ultralytics", "numpy", "cv2"):
        try:
            mod = __import__(mod_name)
            ver = getattr(mod, "__version__", "?")
            path = getattr(mod, "__file__", "?")
            print(f"  ✓ {mod_name:18s} {ver:12s} {path}")
        except BaseException as exc:
            print(f"  ✗ {mod_name:18s} {type(exc).__name__}: {exc}")
    print()
    print("Subsystems available (after constructing main module globals):")
    for name in sorted(SUBSYSTEMS):
        print(f"  {'✓' if SUBSYSTEMS[name] is not None else '✗'} {name}")
    print("=" * 60)


def main():
    parser = argparse.ArgumentParser(description="Sanad Robot Assistant")
    parser.add_argument("--host", default=DASHBOARD_HOST,
                        help=f"Dashboard bind address. Default is wlan0's IP "
                             f"({DASHBOARD_HOST!r}). Override with SANAD_DASHBOARD_HOST "
                             f"or SANAD_DASHBOARD_INTERFACE.")
    parser.add_argument("--port", type=int, default=DASHBOARD_PORT)
    parser.add_argument("--network", default=DDS_NETWORK_INTERFACE,
                        help="DDS network interface (e.g. eth0, lo). "
                             "Override with SANAD_DDS_INTERFACE env var.")
    parser.add_argument("--check-env", action="store_true",
                        help="Print environment diagnostic and exit "
                             "(no server start, no hardware init)")
    args = parser.parse_args()

    if args.check_env:
        _print_env_diagnostic()
        return

    log.info("Sanad starting — Python %s @ %s", sys.version.split()[0], sys.executable)
    log.info("BASE_DIR: %s", _THIS_DIR)
    log.info("Dashboard interface: %s → bound to %s", DASHBOARD_INTERFACE, args.host)
    log.info("Starting Sanad — host=%s port=%d network=%s", args.host, args.port, args.network)
    if brain is not None:
        try:
            log.info("Brain status: %s", brain.status())
        except Exception:
            log.exception("brain.status() failed")
    # Initialize hardware (graceful if unavailable)
    if arm is not None:
        try:
            arm.init(network_interface=args.network)
        except Exception:
            log.exception("arm.init() failed — continuing without hardware")

    # ── import uvicorn ──────────────────────────────────────────────────
    # Catch ANY exception (not just ImportError) so the real failure reason
    # is surfaced. The previous narrow catch hid issues like uvicorn pulling
    # in a broken transitive dep, or being installed under a different
    # site-packages than the active interpreter.
    uvicorn = None
    try:
        import uvicorn  # type: ignore
        log.info("uvicorn %s loaded from %s",
                 getattr(uvicorn, "__version__", "?"),
                 getattr(uvicorn, "__file__", "?"))
    except BaseException as exc:
        log.error("Could not import uvicorn: %s: %s", type(exc).__name__, exc)
        log.error("Python: %s", sys.executable)
        log.error("sys.path[0:5]: %s", sys.path[:5])
        log.error("Try: %s -m pip install --user 'uvicorn[standard]' fastapi", sys.executable)
        sys.exit(1)

    # ── import the FastAPI app ──────────────────────────────────────────
    # Pass the app object directly so uvicorn doesn't have to re-resolve the
    # import path (which differs between dev and deployed layouts).
    try:
        from Project.Sanad.dashboard.app import app as _app
    except BaseException:
        log.exception("Could not import Dashboard.app — aborting")
        sys.exit(1)

    # ── start the server ────────────────────────────────────────────────
    try:
        uvicorn.run(
            _app,
            host=args.host,
            port=args.port,
            log_level="info",
        )
    except BaseException:
        log.exception("uvicorn.run() failed")
        sys.exit(1)


if __name__ == "__main__":
    main()
