#!/usr/bin/env python3
"""VISCA-over-IP bro for Sorlandets Travpark autotracker (versjon 5).

Kjor scriptet pa PC-en som er pa samme nett som PTZ-kameraet:
    Kun lokal styring:
        python visca_bridge.py
    Med fjernstyring fra mobil (via sky):
        python visca_bridge.py <app-url> <nokkel> <kamera-ip>
    Eksempel:
        python visca_bridge.py https://din-app.emergent.host abc123def456 192.168.1.100

v4: zoom-grenser handheves i broen, zoom-sporring med fallback for eldre kameraer.
v5: sporringer bruker egne sockets og blokkerer ALDRI pan/tilt/zoom-kommandoer.
v6: sender ikke reset-pakke ved oppstart.
v7: SERIELL-modus for kameraer uten VISCA over IP (f.eks. BRC-H900 via
    USB->RS-422-adapter). Krever: pip install pyserial
    Finn porten:   python visca_bridge.py --list-ports
    Start seriell: python visca_bridge.py <app-url> <nokkel> - --serial COM3
    Auto-oppdag:   python visca_bridge.py <app-url> <nokkel> - --serial auto
    (kamera-ip kan vaere - i seriell-modus)
v8: seriell-modus faktisk implementert + auto-oppdagelse av COM-port.
    Mobil-kommandoer uten kamera-IP bruker naa siste kjente IP fra lokal styring,
    og broen skriver tydelig i konsollen hva som skjer med hver mobil-kommando.
v9: broen melder tilbake til skyen (ack) for hver mobil-kommando, slik at
    MOBILEN selv viser gronn kvittering "Kameraet mottok" - da slipper man aa
    lese konsollen for aa se om kommandoen kom fram. Tydelig oppstartsbanner.
v10: automatisk VISCA-sekvens-reset forste gang broen styrer et kamera i okten.
    Loser at kameraet ignorerer kommandoer etter en omstart av broen (sekvensnr
    ute av synk). Gjelder bade lokal styring og mobil.
v11: heartbeat hvert sekund til skyen -> mobilen ser tap av produksjons-PC innen
    ~2 sek. Rask gjentilkobling (eksponentiell backoff 1-5s) i relay-loopen.
"""
import json
import os
import socket
import struct
import sys
import threading
import time
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

PORT = 52380
VERSION = 11

_args = [a for a in sys.argv[1:]]
SERIAL_PORT = None
SERIAL_BAUD = 9600
if '--list-ports' in _args:
    try:
        from serial.tools import list_ports
        for p in list_ports.comports():
            print('%s  -  %s' % (p.device, p.description))
    except ImportError:
        print('pyserial mangler. Installer med:  pip install pyserial')
    sys.exit(0)
if '--serial' in _args:
    i = _args.index('--serial')
    SERIAL_PORT = _args[i + 1]
    del _args[i:i + 2]
if '--baud' in _args:
    i = _args.index('--baud')
    SERIAL_BAUD = int(_args[i + 1])
    del _args[i:i + 2]

RELAY_URL = _args[0].rstrip('/') if len(_args) > 0 else ''
RELAY_TOKEN = _args[1] if len(_args) > 1 else ''
DEFAULT_CAMERA_IP = _args[2] if len(_args) > 2 and _args[2] != '-' else ''

LIMITS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'visca_bridge_limits.json')

_cmd_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
_cmd_sock.settimeout(0.02)
_cmd_lock = threading.Lock()
_seq_lock = threading.Lock()
_seq = {}
_limits = {}       # ip -> {'max': int|None, 'min': int|None}
_zoom_state = {}   # ip -> {'dir': 'tele'|'wide'|None}
_zoom_cache = {}   # ip -> (pos, timestamp)
_inq_ptype = {}    # ip -> pakketype som kameraet faktisk svarer paa
_last_ip = {'v': ''}  # siste kamera-IP brukt fra lokal styring (fallback for mobil)
_reset_done = set()   # ip-er som allerede har faatt sekvens-reset i denne okten

_ser = None
_ser_lock = threading.Lock()


def _serial_open():
    global _ser, SERIAL_PORT
    if _ser is not None:
        return _ser
    import serial
    if SERIAL_PORT.lower() == 'auto':
        from serial.tools import list_ports
        cands = list(list_ports.comports())
        if not cands:
            raise RuntimeError('Fant ingen COM-porter. Koble til adapteren og prov igjen.')
        pick = None
        for p in cands:
            d = (p.description or '').lower()
            if 'usb' in d or 'serial' in d or 'uart' in d or 'com' in d:
                pick = p.device
                break
        SERIAL_PORT = pick or cands[0].device
        print('Seriell auto-oppdagelse valgte: %s' % SERIAL_PORT)
    _ser = serial.Serial(SERIAL_PORT, SERIAL_BAUD, timeout=0.05)
    print('Seriell port apnet: %s @ %d baud' % (SERIAL_PORT, SERIAL_BAUD))
    return _ser


def _load_limits():
    global _limits
    try:
        with open(LIMITS_FILE) as f:
            _limits = json.load(f)
    except Exception:
        _limits = {}


def _save_limits():
    try:
        with open(LIMITS_FILE, 'w') as f:
            json.dump(_limits, f)
    except Exception:
        pass


def _next_seq(ip):
    with _seq_lock:
        seq = _seq.get(ip, 0) + 1
        if seq > 0xFFFFFFFF:
            seq = 1
        _seq[ip] = seq
        return seq


def send_reset(ip, port):
    if SERIAL_PORT:
        return
    with _cmd_lock:
        with _seq_lock:
            _seq[ip] = 0
        header = struct.pack('>HHI', 0x0200, 1, 0)
        _cmd_sock.sendto(header + b'\x01', (ip, port))
        try:
            _cmd_sock.recvfrom(1024)
        except (socket.timeout, OSError):
            pass


def _raw_send(ip, port, payload_hex):
    """Kommandoer: sendes umiddelbart, blokkerer aldri pa svar."""
    if SERIAL_PORT:
        with _ser_lock:
            try:
                s = _serial_open()
                s.write(bytes.fromhex(payload_hex))
                s.read(64)
            except Exception as e:
                print('Seriell-feil: %s' % e)
        return
    with _cmd_lock:
        payload = bytes.fromhex(payload_hex)
        header = struct.pack('>HHI', 0x0100, len(payload), _next_seq(ip))
        _cmd_sock.sendto(header + payload, (ip, port))
        try:
            while True:
                _cmd_sock.recvfrom(1024)
        except (socket.timeout, OSError):
            pass


def _send_inquiry(ip, port, payload_hex, ptype, wait=0.5):
    """Sporring pa egen socket - forstyrrer ikke kommandoflyten."""
    if SERIAL_PORT:
        with _ser_lock:
            try:
                s = _serial_open()
                s.reset_input_buffer()
                s.write(bytes.fromhex(payload_hex))
                deadline = time.time() + wait
                buf = b''
                while time.time() < deadline:
                    buf += s.read(32)
                    i = buf.find(b'\x90\x50')
                    if i >= 0:
                        j = buf.find(b'\xff', i)
                        if j >= 0:
                            return buf[i:j + 1].hex()
            except Exception as e:
                print('Seriell-feil (sporring): %s' % e)
        return None
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.settimeout(0.1)
    try:
        payload = bytes.fromhex(payload_hex)
        header = struct.pack('>HHI', ptype, len(payload), _next_seq(ip))
        s.sendto(header + payload, (ip, port))
        deadline = time.time() + wait
        while time.time() < deadline:
            try:
                data, _addr = s.recvfrom(1024)
            except (socket.timeout, OSError):
                continue
            if len(data) > 8:
                reply = data[8:]
                if len(reply) >= 2 and reply[0] == 0x90 and reply[1] == 0x50:
                    return reply.hex()
        return None
    finally:
        s.close()


def inquiry_with_fallback(ip, port, payload_hex):
    """Prov husket pakketype forst, ellers 0x0110 og deretter 0x0100."""
    known = _inq_ptype.get(ip)
    order = [known] if known else []
    order += [t for t in (0x0110, 0x0100) if t != known]
    for ptype in order:
        reply = _send_inquiry(ip, port, payload_hex, ptype)
        if reply is not None:
            _inq_ptype[ip] = ptype
            return reply
    return None


def get_zoom_pos(ip, port=52381):
    reply = inquiry_with_fallback(ip, port, '81090447FF')
    if reply and len(reply) >= 12:
        try:
            pos = ((int(reply[5], 16) << 12) | (int(reply[7], 16) << 8)
                   | (int(reply[9], 16) << 4) | int(reply[11], 16))
            _zoom_cache[ip] = (pos, time.time())
            return pos
        except ValueError:
            return None
    return None


def _cached_zoom_pos(ip, max_age=1.2):
    entry = _zoom_cache.get(ip)
    if entry and time.time() - entry[1] < max_age:
        return entry[0]
    return None


def _classify_zoom(payload_hex):
    p = payload_hex.upper()
    if p.startswith('81010407') and len(p) == 12:
        b = p[8:10]
        if b == '00':
            return 'stop'
        if b[0] == '2':
            return 'tele'
        if b[0] == '3':
            return 'wide'
    return None


def send_visca(ip, port, payload_hex):
    """Send kommando. Zoom-grenser sjekkes mot cachet posisjon (blokkerer aldri)."""
    # Synkroniser sekvensnr forste gang vi styrer et kamera i denne okten.
    # Uten dette ignorerer kameraet kommandoer etter en omstart av broen.
    if not SERIAL_PORT and ip and ip not in _reset_done:
        _reset_done.add(ip)
        print('Synkroniserer sekvensnr mot %s (VISCA-reset)' % ip)
        send_reset(ip, port)
        time.sleep(0.05)
    z = _classify_zoom(payload_hex)
    if z in ('tele', 'wide'):
        lim = _limits.get(ip) or {}
        bound = lim.get('max') if z == 'tele' else lim.get('min')
        if bound is not None:
            pos = _cached_zoom_pos(ip)
            if pos is not None and ((z == 'tele' and pos >= bound) or (z == 'wide' and pos <= bound)):
                payload_hex = '8101040700FF'
                z = 'stop'
        _zoom_state.setdefault(ip, {})['dir'] = None if z == 'stop' else z
    elif z == 'stop':
        _zoom_state.setdefault(ip, {})['dir'] = None
    _raw_send(ip, port, payload_hex)


def zoom_enforcer():
    """Overvaker aktiv zooming (egen socket) og stopper ved grensene."""
    while True:
        time.sleep(0.2)
        for ip, st in list(_zoom_state.items()):
            d = st.get('dir')
            if d not in ('tele', 'wide'):
                continue
            lim = _limits.get(ip) or {}
            bound = lim.get('max') if d == 'tele' else lim.get('min')
            if bound is None:
                continue
            pos = get_zoom_pos(ip)
            if pos is None:
                continue
            if (d == 'tele' and pos >= bound) or (d == 'wide' and pos <= bound):
                _raw_send(ip, 52381, '8101040700FF')
                st['dir'] = None


class Handler(BaseHTTPRequestHandler):
    def _cors(self):
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type')
        self.send_header('Access-Control-Allow-Private-Network', 'true')

    def _json(self, code, obj):
        body = json.dumps(obj).encode()
        self.send_response(code)
        self._cors()
        self.send_header('Content-Type', 'application/json')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_OPTIONS(self):
        self.send_response(204)
        self._cors()
        self.end_headers()

    def do_GET(self):
        if self.path == '/health':
            self._json(200, {'status': 'ok', 'version': VERSION})
        elif self.path.startswith('/limits'):
            qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
            ip = (qs.get('ip') or [''])[0]
            self._json(200, _limits.get(ip) or {'max': None, 'min': None})
        else:
            self._json(404, {'error': 'not found'})

    def do_POST(self):
        try:
            length = int(self.headers.get('Content-Length', 0))
            data = json.loads(self.rfile.read(length) or b'{}')
            ip = data.get('ip') or ('serial' if SERIAL_PORT else '')
            port = int(data.get('port', 52381))
            if not ip:
                self._json(400, {'ok': False, 'error': 'ip mangler'})
                return
            if not SERIAL_PORT:
                _last_ip['v'] = ip
            if self.path == '/reset':
                send_reset(ip, port)
                self._json(200, {'ok': True})
            elif self.path == '/visca':
                send_visca(ip, port, str(data.get('hex', '')).replace(' ', ''))
                self._json(200, {'ok': True})
            elif self.path == '/inquiry':
                reply = inquiry_with_fallback(ip, port, str(data.get('hex', '')).replace(' ', ''))
                self._json(200, {'ok': reply is not None, 'reply': reply})
            elif self.path == '/limits/set-here':
                which = data.get('which')
                pos = get_zoom_pos(ip, port)
                if pos is None:
                    self._json(200, {'ok': False, 'error': 'Kameraet svarte ikke pa zoom-sporring'})
                    return
                lim = _limits.setdefault(ip, {'max': None, 'min': None})
                lim['max' if which == 'max' else 'min'] = pos
                _save_limits()
                self._json(200, {'ok': True, 'pos': pos, 'limits': lim})
            elif self.path == '/limits':
                lim = _limits.setdefault(ip, {'max': None, 'min': None})
                if 'max' in data:
                    lim['max'] = data['max']
                if 'min' in data:
                    lim['min'] = data['min']
                _save_limits()
                self._json(200, {'ok': True, 'limits': lim})
            else:
                self._json(404, {'error': 'not found'})
        except Exception as e:
            self._json(500, {'ok': False, 'error': str(e)})

    def log_message(self, *args):
        pass


def _post_ack(hex_payload, ip):
    """Melder tilbake til skyen at broen mottok/utforte en kommando (for mobil-diagnose)."""
    if not (RELAY_URL and RELAY_TOKEN):
        return
    try:
        body = json.dumps({
            'token': RELAY_TOKEN, 'hex': hex_payload, 'ip': ip or '', 'version': VERSION,
        }).encode()
        req = urllib.request.Request(
            '%s/api/ptz-relay/ack' % RELAY_URL, data=body,
            headers={'Content-Type': 'application/json', 'User-Agent': 'visca-bridge/9'},
        )
        urllib.request.urlopen(req, timeout=5).read()
    except Exception as e:
        print('  (klarte ikke melde tilbake til sky: %s)' % e)


def heartbeat_loop():
    """Sender en lett puls til skyen hvert sekund, slik at mobilen ser om
    produksjons-PC-en er tilkoblet innen ~2 sek (ikke opptil 35)."""
    if not (RELAY_URL and RELAY_TOKEN):
        return
    body = json.dumps({'token': RELAY_TOKEN}).encode()
    while True:
        try:
            req = urllib.request.Request(
                '%s/api/ptz-relay/heartbeat' % RELAY_URL, data=body,
                headers={'Content-Type': 'application/json', 'User-Agent': 'visca-bridge/11'},
            )
            urllib.request.urlopen(req, timeout=4).read()
        except Exception:
            pass
        time.sleep(1)


def relay_loop():
    """Henter fjernstyringskommandoer fra skyserveren (long-poll)."""
    print('Fjernstyring aktiv mot %s' % RELAY_URL)
    backoff = 1
    while True:
        try:
            url = '%s/api/ptz-relay/poll?token=%s&wait=25' % (RELAY_URL, urllib.parse.quote(RELAY_TOKEN))
            req = urllib.request.Request(url, headers={'User-Agent': 'visca-bridge/11'})
            with urllib.request.urlopen(req, timeout=35) as r:
                data = json.loads(r.read() or b'{}')
            backoff = 1
            for cmd in data.get('commands', []):
                if SERIAL_PORT:
                    ip = 'serial'
                else:
                    ip = cmd.get('ip') or DEFAULT_CAMERA_IP or _last_ip['v']
                payload = str(cmd.get('hex', '')).replace(' ', '')
                if ip and payload:
                    print('[MOBIL] kommando %s  ->  %s' % (payload, ip))
                    send_visca(ip, 52381, payload)
                    _post_ack(payload, ip)
                elif payload:
                    print('[MOBIL] ADVARSEL: kommando %s DROPPET - broen kjenner ingen kamera-IP.'
                          ' Start broen med kamera-IP som 3. argument, eller apne'
                          ' Autotrack-fanen i kontrollpanelet forst.' % payload)
                    _post_ack(payload, '')
        except Exception as e:
            print('Fjernstyring: mistet kontakt (%s) - kobler til igjen om %d sek' % (e, backoff))
            time.sleep(backoff)
            backoff = min(backoff * 2, 5)  # rask gjentilkobling: 1,2,4,5,5...


if __name__ == '__main__':
    _load_limits()
    print('=' * 52)
    print('  VISCA-bro v%d kjorer pa http://127.0.0.1:%d' % (VERSION, PORT))
    print('=' * 52)
    if SERIAL_PORT:
        print('Modus: SERIELL (%s @ %d baud)' % (SERIAL_PORT, SERIAL_BAUD))
        try:
            _serial_open()
        except Exception as e:
            print('ADVARSEL: klarte ikke apne seriell port: %s' % e)
    elif DEFAULT_CAMERA_IP:
        print('Modus: VISCA over IP - kamera %s:52381' % DEFAULT_CAMERA_IP)
    else:
        print('Modus: VISCA over IP - kamera-IP hentes fra hver kommando')
    threading.Thread(target=zoom_enforcer, daemon=True).start()
    if RELAY_URL and RELAY_TOKEN:
        threading.Thread(target=relay_loop, daemon=True).start()
        threading.Thread(target=heartbeat_loop, daemon=True).start()
    else:
        print('(Fjernstyring ikke aktivert - start med: python visca_bridge.py <app-url> <nokkel> <kamera-ip>)')
    print('La dette vinduet sta apent. Ctrl+C for a stoppe.')
    ThreadingHTTPServer(('127.0.0.1', PORT), Handler).serve_forever()
