import os
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request

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


DEFAULT_VIS_PORT = 5088
DEFAULT_VIEW = 3
_SERVER_PROCESS = None
_VISIBLE_PROCESS = None


def _package_root() -> str:
    return os.path.dirname(os.path.abspath(__file__))


def _vis_runtime_candidates():
    # Frozen environment (PyInstaller): check _MEIPASS first
    meipass = getattr(sys, "_MEIPASS", None)
    if meipass:
        yield os.path.join(meipass, "vis_runtime")
    pkg_root = _package_root()
    yield os.path.join(pkg_root, "vis")
    env_dir = os.environ.get("PHG_VIS_RUNTIME")
    if env_dir:
        yield env_dir


def resolve_vis_runtime_dir() -> str | None:
    for candidate in _vis_runtime_candidates():
        if candidate and os.path.isdir(candidate) and os.path.exists(os.path.join(candidate, "vis.exe")):
            return candidate
    return None


def _vis_exe_path() -> str:
    runtime_dir = resolve_vis_runtime_dir()
    if not runtime_dir:
        raise RuntimeError(
            "VIS runtime not found. Expected phg/vis/vis.exe or set PHG_VIS_RUNTIME to the runtime folder."
        )
    return os.path.join(runtime_dir, "vis.exe")


def is_vis_running() -> bool:
    if psutil is None:
        return _port_open()
    for proc in psutil.process_iter(["name"]):
        if proc.info.get("name") == "vis.exe":
            return True
    return _port_open()


def _port_open(host: str = "127.0.0.1", port: int = DEFAULT_VIS_PORT) -> bool:
    try:
        with socket.create_connection((host, port), timeout=0.25):
            return True
    except OSError:
        return False


def _wait_for_port(timeout: float) -> None:
    end_time = time.time() + timeout
    while time.time() < end_time:
        if _port_open():
            return
        time.sleep(0.2)
    raise RuntimeError("VIS render server did not start in time.")


def _launch_vis(hidden: bool) -> str:
    global _SERVER_PROCESS
    global _VISIBLE_PROCESS

    runtime_dir = resolve_vis_runtime_dir()
    if not runtime_dir:
        raise RuntimeError(
            "VIS runtime directory not found. Reinstall phg_vis, copy the vis runtime beside the phg package, "
            "or set PHG_VIS_RUNTIME."
        )

    vis_exe = os.path.join(runtime_dir, "vis.exe")
    creationflags = 0
    startupinfo = None
    if os.name == "nt" and hidden:
        creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
        startupinfo = subprocess.STARTUPINFO()
        startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
        startupinfo.wShowWindow = 0

    proc = subprocess.Popen(
        [vis_exe],
        cwd=runtime_dir,
        stdout=subprocess.DEVNULL if hidden else None,
        stderr=subprocess.DEVNULL if hidden else None,
        stdin=subprocess.DEVNULL if hidden else None,
        creationflags=creationflags,
        startupinfo=startupinfo,
    )
    if hidden:
        _SERVER_PROCESS = proc
    else:
        _VISIBLE_PROCESS = proc
    return runtime_dir


def ensure_vis_server(timeout: float = 15.0) -> str:
    runtime_dir = resolve_vis_runtime_dir()
    if not runtime_dir:
        raise RuntimeError(
            "VIS runtime directory not found. Reinstall phg_vis, copy the vis runtime beside the phg package, "
            "or set PHG_VIS_RUNTIME."
        )
    if _port_open():
        return runtime_dir

    _launch_vis(hidden=True)
    _wait_for_port(timeout)
    return runtime_dir


def start_visible_vis(timeout: float = 15.0) -> str:
    runtime_dir = resolve_vis_runtime_dir()
    if not runtime_dir:
        raise RuntimeError(
            "VIS runtime directory not found. Reinstall phg_vis, copy the vis runtime beside the phg package, "
            "or set PHG_VIS_RUNTIME."
        )
    if _port_open():
        return runtime_dir
    _launch_vis(hidden=False)
    _wait_for_port(timeout)
    return runtime_dir


def _normalize_script(script: str) -> str:
    text = (script or "").strip()
    if not text:
        text = "{ viewer{ md:cube 10; rgb:120,170,230; } }"
    low = text.lower()
    if "setup_draw(" in low or "setup();" in low or "draw();" in low:
        return text
    if text.endswith("}"):
        return text + "\nsetup_draw();"
    return text


def _request_bytes(
    endpoint: str,
    body: str,
    timeout: float = 30.0,
    host: str = "127.0.0.1",
    port: int = DEFAULT_VIS_PORT,
) -> bytes:
    url = f"http://{host}:{port}{endpoint}"
    req = urllib.request.Request(
        url,
        data=body.encode("utf-8"),
        headers={"Content-Type": "text/plain; charset=utf-8"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return resp.read()
    except urllib.error.HTTPError as ex:
        detail = ex.read().decode("utf-8", errors="replace").strip()
        raise RuntimeError(detail or f"VIS HTTP error {ex.code}") from ex
    except urllib.error.URLError as ex:
        raise RuntimeError(f"VIS request failed: {ex.reason}") from ex


def send_phg(script: str, timeout: float = 15.0) -> None:
    ensure_vis_server()
    _request_bytes("/phg", _normalize_script(script), timeout=timeout)


def show_phg(script: str, timeout: float = 15.0) -> None:
    start_visible_vis(timeout=timeout)
    _request_bytes("/phg", _normalize_script(script), timeout=timeout)


def render_png_bytes(
    script: str,
    width: int = 800,
    height: int = 600,
    view: int = DEFAULT_VIEW,
    timeout: float = 30.0,
    *,
    orbit: bool | None = None,
    autofit: bool | None = None,
    yaw: float | None = None,
    pitch: float | None = None,
    zoom: float | None = None,
) -> bytes:
    ensure_vis_server()
    params = [
        f"view={int(view)}",
        f"width={int(width)}",
        f"height={int(height)}",
    ]
    if orbit is not None:
        params.append(f"orbit={'true' if orbit else 'false'}")
    if autofit is not None:
        params.append(f"autofit={'true' if autofit else 'false'}")
    if yaw is not None:
        params.append(f"yaw={float(yaw):.3f}")
    if pitch is not None:
        params.append(f"pitch={float(pitch):.3f}")
    if zoom is not None:
        params.append(f"zoom={float(zoom):.4f}")
    payload = f"{_normalize_script(script)}|||{' '.join(params)}"
    data = _request_bytes("/phg_img", payload, timeout=timeout)
    if not data.startswith(b"\x89PNG"):
        raise RuntimeError("VIS did not return a PNG image.")
    return data


def render_png(
    script: str,
    filename: str = "shot.png",
    width: int = 800,
    height: int = 600,
    view: int = DEFAULT_VIEW,
    timeout: float = 30.0,
    *,
    orbit: bool | None = None,
    autofit: bool | None = None,
    yaw: float | None = None,
    pitch: float | None = None,
    zoom: float | None = None,
) -> str:
    png = render_png_bytes(
        script,
        width=width,
        height=height,
        view=view,
        timeout=timeout,
        orbit=orbit,
        autofit=autofit,
        yaw=yaw,
        pitch=pitch,
        zoom=zoom,
    )
    out_path = os.path.abspath(filename)
    os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
    with open(out_path, "wb") as f:
        f.write(png)
    return out_path
