"""`python -m bolt_pet --doctor` — is this install actually going to work?

Written after a week of debugging things a preflight would have shown in one
command: a missing port publish, a wrong reverse-proxy header, a venv whose
python was a zero-byte file, an OCR engine that was never installed. Every one
of those presented as "the pet is being weird" and took a conversation to find.

Each check is independent and reports one of three things — ok, a warning
(works, but degraded), or a failure (this will not do what you expect) — and
says *what to do about it* rather than just what is wrong. Nothing here raises:
a doctor that crashes on a broken install is diagnosing the wrong patient.

Deliberately does not open the mic or call a paid API by default. It checks
that the door is unlocked, not that the room is furnished; `--deep` is there
when you want it to actually knock.
"""

from __future__ import annotations

import importlib
import shutil
import sys
from dataclasses import dataclass
from typing import Callable, Optional

from . import config

OK, WARN, FAIL = "ok", "warn", "fail"

_MARKS = {OK: "  ok  ", WARN: " warn ", FAIL: " FAIL "}


@dataclass
class Check:
    name: str
    status: str
    detail: str = ""
    fix: str = ""

    def line(self) -> str:
        text = f"[{_MARKS[self.status]}] {self.name:22} {self.detail}"
        if self.fix and self.status != OK:
            text += f"\n{'':32}→ {self.fix}"
        return text


def _module(name: str) -> bool:
    try:
        importlib.import_module(name)
        return True
    except Exception:
        return False


# ── the checks ──────────────────────────────────────────────────────────────

def check_config() -> Check:
    missing = config.missing_config()
    if missing:
        return Check("server config", FAIL, f"missing {', '.join(missing)}",
                     "set them in .env — without these the controller exits at startup")
    return Check("server config", OK, f"{config.SERVER_URL} as {config.SESSION_ID}")


def check_server(deep: bool = False) -> Check:
    if not config.SERVER_URL:
        return Check("server", FAIL, "no BOLT_SERVER_URL",
                     "cp .env.example .env and set BOLT_SERVER_URL to your Bolt server")
    if not deep:
        return Check("server", OK, f"{config.SERVER_URL} (not contacted; --deep to try)")
    try:
        from . import server_client

        health = server_client.check_health(timeout=8)
        return Check("server", OK, f"reachable — {health}")
    except Exception as exc:
        return Check("server", FAIL, f"unreachable: {exc}",
                     "check the URL, the key, and that the container is up")


def check_streaming_endpoint(deep: bool = False) -> Check:
    """The streamed-reply endpoint is newer than some deployed servers."""
    if not config.STREAMING_REPLIES:
        return Check("streamed replies", WARN, "disabled (STREAMING_REPLIES=false)")
    if not deep:
        return Check("streamed replies", OK, "enabled (endpoint not probed)")
    try:
        import requests

        response = requests.post(
            f"{config.SERVER_URL}/desk/converse_stream",
            json={"session_id": config.SESSION_ID, "text": ""},
            headers={"X-Desk-Api-Key": config.API_KEY}, timeout=8, stream=True,
        )
        if response.status_code == 404:
            return Check("streamed replies", WARN, "server has no /desk/converse_stream",
                         "update the server, or set STREAMING_REPLIES=false to skip the probe")
        return Check("streamed replies", OK, f"endpoint answered {response.status_code}")
    except Exception as exc:
        return Check("streamed replies", WARN, f"probe failed: {exc}")


def check_microphone(deep: bool = False) -> Check:
    if not _module("sounddevice"):
        return Check("microphone", FAIL, "sounddevice is not installed",
                     "pip install -r requirements.txt")
    try:
        import sounddevice as sd

        devices = [d for d in sd.query_devices() if d.get("max_input_channels", 0) > 0]
        if not devices:
            return Check("microphone", FAIL, "no input devices",
                         "on Linux check PipeWire/PulseAudio is running as your user")
        chosen = config.MIC_DEVICE or "system default"
        if not deep:
            return Check("microphone", OK, f"{len(devices)} input device(s), using {chosen}")
        with sd.InputStream(samplerate=config.SAMPLE_RATE, channels=1, dtype="int16",
                            device=config.MIC_DEVICE, blocksize=config.FRAME_LEN):
            pass
        return Check("microphone", OK, f"opened at {config.SAMPLE_RATE} Hz ({chosen})")
    except Exception as exc:
        return Check("microphone", FAIL, f"could not open: {exc}",
                     "don't run the pet as root — PortAudio can't reach your PipeWire socket")


def check_wake_model() -> Check:
    from pathlib import Path

    path = Path(config.WAKE_MODEL_PATH)
    if not path.exists():
        return Check("wake word", FAIL, f"{path.name} is missing",
                     "it ships in the project root; check WAKE_MODEL_FILE")
    if not _module("openwakeword"):
        return Check("wake word", FAIL, "openwakeword is not installed",
                     "pip install -r requirements.txt")
    return Check("wake word", OK, f"{path.name}, threshold {config.WAKE_WORD_THRESHOLD}")


def check_stt() -> Check:
    if not config.DEEPGRAM_API_KEY:
        return Check("speech-to-text", FAIL, "no DEEPGRAM_API_KEY",
                     "set it in .env — nothing you say can be transcribed without it")
    if config.STT_STREAMING and not _module("websocket"):
        return Check("speech-to-text", WARN, "streaming on, but websocket-client is missing",
                     "pip install websocket-client — it falls back to one-shot uploads")
    mode = "streaming" if config.STT_STREAMING else "one-shot"
    return Check("speech-to-text", OK, f"Deepgram {config.DEEPGRAM_MODEL}, {mode}")


def check_tts() -> Check:
    if not (config.ELEVENLABS_API_KEY and config.ELEVENLABS_VOICE_ID):
        if _module("pyttsx3"):
            return Check("text-to-speech", WARN, "no ElevenLabs key/voice — offline voice only",
                         "set ELEVENLABS_API_KEY and ELEVENLABS_VOICE_ID for the real voice")
        return Check("text-to-speech", FAIL, "no ElevenLabs config and no pyttsx3 fallback")
    return Check("text-to-speech", OK,
                 f"ElevenLabs {config.ELEVENLABS_MODEL_ID}, voice …{config.ELEVENLABS_VOICE_ID[-6:]}")


def check_dialogue() -> Check:
    if not config.DIALOGUE:
        return Check("multi-voice scenes", WARN, "disabled (DIALOGUE=false)")
    from . import dialogue

    cast = dialogue.parse_voice_map(config.DIALOGUE_VOICES)
    if not cast:
        return Check("multi-voice scenes", WARN, "no cast configured — only 'self' works",
                     'set DIALOGUE_VOICES=narrator:<id>,villain:<id>')
    return Check("multi-voice scenes", OK, f"{len(cast)} voice(s): {', '.join(sorted(cast))}")


def check_screen_text() -> Check:
    if not config.SCREEN_TEXT:
        return Check("screen reading", WARN, "disabled (SCREEN_TEXT=false)")
    if not _module("mss"):
        return Check("screen reading", WARN, "mss is not installed — petctl read will decline",
                     "pip install mss (and note it cannot capture on Wayland)")
    engine = "pytesseract" if _module("pytesseract") else (
        "rapidocr" if _module("rapidocr_onnxruntime") else "")
    if not engine:
        return Check("screen reading", WARN, "no OCR engine",
                     "pip install pytesseract && apt install tesseract-ocr, "
                     "or pip install rapidocr-onnxruntime")
    if engine == "pytesseract" and not shutil.which("tesseract"):
        return Check("screen reading", WARN, "pytesseract is installed but tesseract is not",
                     "apt install tesseract-ocr")
    return Check("screen reading", OK, f"mss + {engine}")


def check_hotkey() -> Check:
    if not _module("pynput"):
        return Check("push-to-talk", WARN, "pynput is not installed",
                     "the wake word still works; pip install pynput for the hotkey")
    import os

    if os.environ.get("WAYLAND_DISPLAY") and not os.environ.get("DISPLAY"):
        return Check("push-to-talk", WARN, "Wayland session — global hotkeys usually blocked",
                     "use the wake word, or click the pet")
    return Check("push-to-talk", OK, config.PUSH_TO_TALK_HOTKEY)


def check_sprites() -> Check:
    from pathlib import Path

    root = Path(__file__).resolve().parent / "assets" / "sprites"
    if not root.exists():
        return Check("sprites", WARN, "no art — the placeholder blob will be drawn",
                     "python scripts/generate_bolt_sprites.py")
    counts = {d.name: len(list(d.glob("*.png"))) for d in sorted(root.iterdir()) if d.is_dir()}
    empty = [name for name, count in counts.items() if count == 0]
    if empty:
        return Check("sprites", WARN, f"no frames for: {', '.join(empty)}",
                     "python scripts/generate_bolt_sprites.py")
    return Check("sprites", OK, ", ".join(f"{n} {c}" for n, c in counts.items()))


def check_latency() -> Check:
    """The setting most likely to make it feel slow, and the least obvious."""
    silence = config.SILENCE_END_SEC
    if silence >= 1.5:
        return Check("turn latency", WARN, f"VAD_SILENCE_END_SEC={silence:g}s of dead air per turn",
                     "0.8-1.0 feels markedly snappier; it is pure wait before anything starts")
    return Check("turn latency", OK, f"silence timeout {silence:g}s, "
                 f"streaming {'on' if config.STREAMING_REPLIES else 'off'}")


CHECKS: tuple[tuple[str, Callable], ...] = (
    ("config", check_config),
    ("server", check_server),
    ("stream", check_streaming_endpoint),
    ("mic", check_microphone),
    ("wake", check_wake_model),
    ("stt", check_stt),
    ("tts", check_tts),
    ("dialogue", check_dialogue),
    ("screen", check_screen_text),
    ("hotkey", check_hotkey),
    ("sprites", check_sprites),
    ("latency", check_latency),
)


def run(deep: bool = False) -> list[Check]:
    results = []
    for _name, check in CHECKS:
        try:
            try:
                results.append(check(deep))
            except TypeError:
                results.append(check())
        except Exception as exc:  # a broken check must not hide the others
            results.append(Check(_name, FAIL, f"the check itself failed: {exc}"))
    return results


def main(argv: Optional[list] = None) -> int:
    argv = list(argv if argv is not None else sys.argv[1:])
    deep = "--deep" in argv
    print(f"Bolt pet preflight{' (deep: contacting the server and opening the mic)' if deep else ''}\n")
    results = run(deep=deep)
    for check in results:
        print(check.line())
    failures = [c for c in results if c.status == FAIL]
    warnings = [c for c in results if c.status == WARN]
    print()
    if failures:
        print(f"{len(failures)} problem(s) will stop this working. Fix those first.")
    elif warnings:
        print(f"Ready. {len(warnings)} thing(s) degraded but working.")
    else:
        print("Everything checks out.")
    return 1 if failures else 0
