#!/usr/bin/env python3
"""
AI Bridge launcher — native messaging host
===========================================
Lets the Chrome extension start/stop the local bridge server (server.py)
without the user running a terminal command. Chrome spawns this script for
each `chrome.runtime.sendNativeMessage` call, we act on one command, reply,
and exit.

Native messaging wire format: a 4-byte little-endian length prefix followed
by a UTF-8 JSON message, on stdin/stdout.

Commands (JSON on stdin): {"command": "start" | "stop" | "status"}
Replies: {"ok": bool, "running": bool, "pid": int|None, ...}

Install the host manifest with bridge/install_native_host.sh so Chrome can
find this script. Uses only the standard library so it runs under the system
python3 named in its shebang.
"""

import sys
import os
import json
import struct
import signal
import socket
import subprocess

HTTP_PORT = 9124  # server.py's HTTP API port; used as a liveness probe

HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
SERVER = os.path.join(HERE, 'server.py')
PID_FILE = os.path.join(HERE, '.bridge.pid')
LOG_FILE = os.path.join(HERE, '.bridge.log')

# Prefer a compiled server binary (no Python install required) sitting next
# to this launcher; fall back to the repo virtualenv; fall back to whatever
# python3 is on PATH. This keeps launcher.py itself as a tiny stdlib-only
# script (it must run under *some* python3 for native messaging to work at
# all), while the actual server — the part with real dependencies
# (aiohttp/websockets) — doesn't require a Python install on the user's
# machine if the compiled binary is present.
_BIN_NAME = 'ai-bridge-server.exe' if os.name == 'nt' else 'ai-bridge-server'
_BINARY = os.path.join(HERE, _BIN_NAME)
_VENV_PY = os.path.join(REPO, '.venv', 'bin', 'python')
if os.path.exists(_BINARY):
    SERVER_CMD = [_BINARY]
elif os.path.exists(_VENV_PY):
    SERVER_CMD = [_VENV_PY, SERVER]
else:
    SERVER_CMD = ['python3', SERVER]


# ── Native messaging framing ────────────────────────────────────────────────

def read_message():
    raw_len = sys.stdin.buffer.read(4)
    if len(raw_len) < 4:
        return None
    n = struct.unpack('<I', raw_len)[0]
    data = sys.stdin.buffer.read(n)
    try:
        return json.loads(data.decode('utf-8'))
    except Exception:
        return None


def send_message(obj):
    data = json.dumps(obj).encode('utf-8')
    sys.stdout.buffer.write(struct.pack('<I', len(data)))
    sys.stdout.buffer.write(data)
    sys.stdout.buffer.flush()


# ── Process management ──────────────────────────────────────────────────────

def read_pid():
    try:
        with open(PID_FILE) as f:
            return int(f.read().strip())
    except Exception:
        return None


def is_running(pid):
    if not pid:
        return False
    try:
        os.kill(pid, 0)  # signal 0 = existence check, no-op
        return True
    except OSError:
        return False


def port_alive(port=HTTP_PORT):
    """True if something is already listening on the bridge's HTTP port.

    Catches the case where the server was started manually (not via this
    launcher, so no PID file) — we must not spawn a duplicate that then
    fails to bind the port.
    """
    try:
        with socket.create_connection(('127.0.0.1', port), timeout=0.5):
            return True
    except OSError:
        return False


def start():
    pid = read_pid()
    if is_running(pid):
        return {'ok': True, 'running': True, 'pid': pid, 'already_running': True}
    if port_alive():
        # A server is up but not one we launched (no valid PID file).
        return {'ok': True, 'running': True, 'pid': None,
                'already_running': True, 'external': True}
    try:
        logf = open(LOG_FILE, 'a')
        # start_new_session so the server lives in its own process group and
        # keeps running after this short-lived launcher exits.
        proc = subprocess.Popen(
            SERVER_CMD,
            stdout=logf, stderr=logf, stdin=subprocess.DEVNULL,
            start_new_session=True, cwd=REPO,
        )
        with open(PID_FILE, 'w') as f:
            f.write(str(proc.pid))
        return {'ok': True, 'running': True, 'pid': proc.pid, 'launched_via': SERVER_CMD[0]}
    except Exception as e:
        return {'ok': False, 'error': str(e)}


def stop():
    pid = read_pid()
    if not is_running(pid):
        _clear_pid()
        if port_alive():
            # Server running but we didn't launch it, so we don't know its PID.
            return {'ok': False, 'running': True, 'external': True,
                    'error': 'A bridge is running that this launcher did not start; '
                             'stop it where it was started.'}
        return {'ok': True, 'running': False}
    try:
        os.killpg(os.getpgid(pid), signal.SIGTERM)
    except Exception:
        try:
            os.kill(pid, signal.SIGTERM)
        except Exception as e:
            return {'ok': False, 'error': str(e), 'pid': pid}
    _clear_pid()
    return {'ok': True, 'running': False, 'stopped_pid': pid}


def status():
    pid = read_pid()
    if is_running(pid):
        return {'ok': True, 'running': True, 'pid': pid}
    # No tracked process — fall back to a port probe so a manually-started
    # server still reads as "running".
    if port_alive():
        return {'ok': True, 'running': True, 'pid': None, 'external': True}
    return {'ok': True, 'running': False, 'pid': None}


def _clear_pid():
    try:
        os.remove(PID_FILE)
    except Exception:
        pass


def main():
    msg = read_message()
    if msg is None:
        return
    cmd = (msg or {}).get('command', 'status')
    if cmd == 'start':
        resp = start()
    elif cmd == 'stop':
        resp = stop()
    else:
        resp = status()
    send_message(resp)


if __name__ == '__main__':
    main()
