#!/usr/bin/env python3
"""
Stream Sky - agent for din egen maskin.

Denne agenten gjoer at DIN maskin - en mini-PC under TV-en, en stasjonaer PC, en Raspberry Pi
eller en Radxa - kan styres fra fjernstyringen paa streamsky.no/kontroll. Du starter og stopper
sendingen fra mobilen, uansett hvor maskinen staar.

  Fjernstyring er inkludert i SRT-linja. Du trenger verken OBS-boks eller Restream.

TO MOTORER I SAMME AGENT
  motor=obs      Du bruker OBS paa maskinen. Vi snakker med OBS over obs-websocket og
                 starter/stopper sendingen der. Vi fyller ogsaa inn SRT-lenka di i OBS av oss
                 selv, saa du slipper aa lime den inn - og den holdes riktig hvis du bytter
                 noekler senere.
  motor=ffmpeg   Du har ingen OBS (typisk en IRL-boks). Vi starter og stopper en ffmpeg- eller
                 belacoder-kommando i stedet. Kommandoen bestemmer du selv.

HVORFOR MASKINEN RINGER OSS, OG IKKE OMVENDT
  Maskinen din staar bak en ruter eller paa mobilnett, og har ingen adresse vi kunne naadd.
  Derfor henter DEN oensket tilstand fra oss hvert par sekunder. Saa lenge den kommer ut paa
  HTTPS virker fjernstyringen - uten portaapning, uten tunnel, uten fast IP.

  Vi sender TILSTAND, ikke kommandoer i koe. Agenten sammenligner det du oensker med det som
  faktisk skjer, og retter opp. Har maskinen vaert av en time, gjoer den bare det som trengs
  naa - den spiller ikke av gamle kommandoer.

SLIK KOMMER DU I GANG
  1. Lag en parekode i portalen: Min side -> stroemmen din -> "Koble til din egen maskin".
  2. python3 streamsky-agent.py par SSKY-XXXX-XXXX-XXXX
  3. python3 streamsky-agent.py oppsett          (velger motor, tester OBS)
  4. python3 streamsky-agent.py kjor             (eller: tjeneste --installer)

  Ingen avhengigheter utenfor Pythons standardbibliotek. Python 3.8 eller nyere.
  Windows, Linux og macOS. Bygget mot enhets-API versjon 1.
  Fri programvare - bruk den, endre den, del den videre.

MERK OM TEGN: fila holder seg til ASCII med vilje. Windows-konsollen kjoerer fortsatt ofte
cp1252, og en norsk bokstav i en utskrift der stopper agenten med UnicodeEncodeError midt i en
sending. Norsk tekst hoerer hjemme i portalen, ikke i et terminalvindu.
"""
import argparse
import base64
import hashlib
import json
import os
import platform
import random
import shlex
import socket
import struct
import subprocess
import sys
import time
import urllib.error
import urllib.request

VERSJON = '1.0.0'
API_VERSJON_BYGD_FOR = 1
BASE = os.environ.get('STREAMSKY_BASE', 'https://panel.streamsky.no')
POLL_SEK = 2.0
RAPPORT_HVER = 10          # runder mellom hver rapport naar ingenting endrer seg


def logg(*a):
    print('[%s]' % time.strftime('%H:%M:%S'), *a, flush=True)


# --------------------------------------------------------------------------- konfig
def konfig_sti():
    """Der konfigen hoerer hjemme paa nettopp denne maskinen.

    Vi skriver ALDRI ved siden av skriptet: kunden legger gjerne skriptet i Nedlastinger, og et
    token i Nedlastinger blir liggende igjen naar mappa ryddes - eller blir delt videre sammen
    med skriptet. Tokenet er maskinens legitimasjon og skal ligge i brukerprofilen.
    """
    egen = os.environ.get('STREAMSKY_KONFIG')
    if egen:
        return egen
    if os.name == 'nt':
        rot = os.environ.get('APPDATA') or os.path.expanduser('~')
        return os.path.join(rot, 'StreamSky', 'agent.json')
    # Kjoerer vi som root (typisk en systemd-tjeneste paa en IRL-boks), hoerer den i /etc.
    # Ellers i brukerens egen katalog, saa agenten kan kjoeres uten sudo.
    if hasattr(os, 'geteuid') and os.geteuid() == 0:
        return '/etc/streamsky/agent.json'
    rot = os.environ.get('XDG_CONFIG_HOME') or os.path.join(os.path.expanduser('~'), '.config')
    return os.path.join(rot, 'streamsky', 'agent.json')


KONFIG = konfig_sti()

STANDARD = {
    'motor': 'obs',
    'obs_host': '127.0.0.1',
    'obs_port': 4455,
    'obs_passord': '',
    # Tom = vi fyller inn SRT-lenka i OBS av oss selv. Setter du den til false, roerer vi
    # aldri stroeminnstillingene dine - for deg som har et oppsett du vil styre helt selv.
    'obs_sett_srt': True,
    'ffmpeg_kommando': '',
    'base': BASE,
}


def les_konfig():
    c = dict(STANDARD)
    try:
        with open(KONFIG, encoding='utf-8') as f:
            c.update(json.load(f))
    except (FileNotFoundError, ValueError):
        pass
    return c


def skriv_konfig(c):
    mappe = os.path.dirname(KONFIG)
    if mappe:
        os.makedirs(mappe, exist_ok=True)
    tmp = KONFIG + '.tmp'
    with open(tmp, 'w', encoding='utf-8') as f:
        json.dump(c, f, indent=2)
    # Tokenet er maskinens legitimasjon. 0600 saa andre brukere paa maskinen ikke kan lese det.
    # Windows arver rettigheter fra brukerprofilen og har ikke denne modellen - chmod er en
    # stille no-op der, og det er greit.
    try:
        os.chmod(tmp, 0o600)
    except OSError:
        pass
    os.replace(tmp, KONFIG)


# --------------------------------------------------------------------------- HTTP
def kall(c, sti, kropp=None, token=None, timeout=10):
    data = json.dumps(kropp).encode() if kropp is not None else None
    hoder = {'Content-Type': 'application/json',
             'User-Agent': 'streamsky-agent/%s' % VERSJON}
    if token:
        # I headeren, ikke i URL-en: mellomledd logger gjerne hele URI-en med query.
        hoder['Authorization'] = 'Bearer ' + token
    req = urllib.request.Request(c.get('base', BASE) + sti, data=data, headers=hoder,
                                 method='POST' if data is not None else 'GET')
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read().decode('utf-8') or '{}')


# --------------------------------------------------------------------------- OBS websocket v5
class ObsWs:
    """Minimal obs-websocket v5-klient.

    Handtrykk og rammer er skrevet ut her i stedet for aa hente inn en pakke: da virker agenten
    med Python-en som allerede ligger paa maskinen, uten pip, uten virtuelt miljoe, og uten at
    en oppgradering av en tredjepartspakke kan ta ned sendingen din.
    """

    def __init__(self, host, port, passord):
        self.host, self.port, self.passord = host, int(port), passord or ''
        self.sock = None

    # ---- rammer ----
    def _send_frame(self, data):
        b = data.encode('utf-8')
        hode = bytearray([0x81])          # FIN + tekst
        maske = os.urandom(4)
        n = len(b)
        if n < 126:
            hode.append(0x80 | n)
        elif n < 65536:
            hode.append(0x80 | 126); hode += struct.pack('>H', n)
        else:
            hode.append(0x80 | 127); hode += struct.pack('>Q', n)
        hode += maske
        hode += bytes(ch ^ maske[i % 4] for i, ch in enumerate(b))
        self.sock.sendall(bytes(hode))

    def _les_n(self, n):
        ut = b''
        while len(ut) < n:
            d = self.sock.recv(n - len(ut))
            if not d:
                raise ConnectionError('lukket')
            ut += d
        return ut

    def _recv_frame(self):
        b1, b2 = self._les_n(2)
        lengde = b2 & 0x7F
        if lengde == 126:
            lengde = struct.unpack('>H', self._les_n(2))[0]
        elif lengde == 127:
            lengde = struct.unpack('>Q', self._les_n(8))[0]
        maske = self._les_n(4) if (b2 & 0x80) else None
        data = self._les_n(lengde)
        if maske:
            data = bytes(ch ^ maske[i % 4] for i, ch in enumerate(data))
        if (b1 & 0x0F) == 0x08:
            raise ConnectionError('OBS lukket forbindelsen')
        return data.decode('utf-8', 'replace')

    # ---- forbindelse ----
    def koble(self):
        self.sock = socket.create_connection((self.host, self.port), timeout=8)
        self.sock.settimeout(8)
        nokkel = base64.b64encode(os.urandom(16)).decode()
        self.sock.sendall((
            'GET / HTTP/1.1\r\nHost: %s:%d\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n'
            'Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n\r\n'
            % (self.host, self.port, nokkel)).encode())
        svar = b''
        while b'\r\n\r\n' not in svar:
            d = self.sock.recv(1024)
            if not d:
                raise ConnectionError('ingen svar fra OBS')
            svar += d
        if b'101' not in svar.split(b'\r\n')[0]:
            raise ConnectionError('OBS avviste tilkoblingen: %s'
                                  % svar.split(b'\r\n')[0].decode('ascii', 'replace'))

        hello = json.loads(self._recv_frame())
        d = hello.get('d', {})
        ident = {'op': 1, 'd': {'rpcVersion': d.get('rpcVersion', 1)}}
        auth = d.get('authentication')
        if auth:
            if not self.passord:
                raise ConnectionError('OBS krever passord for websocket. Sett det med: '
                                      'streamsky-agent.py oppsett --obs-passord <passord>')
            # obs-websocket v5: base64(sha256(base64(sha256(pw+salt)) + challenge))
            hemmelig = base64.b64encode(
                hashlib.sha256((self.passord + auth['salt']).encode()).digest()).decode()
            ident['d']['authentication'] = base64.b64encode(
                hashlib.sha256((hemmelig + auth['challenge']).encode()).digest()).decode()
        self._send_frame(json.dumps(ident))
        svar = json.loads(self._recv_frame())
        if svar.get('op') != 2:
            raise ConnectionError('OBS godtok ikke passordet')
        return True

    def _kall(self, type_, data=None):
        rid = 'ss-%d' % random.randint(1, 10 ** 9)
        self._send_frame(json.dumps({'op': 6, 'd': {'requestType': type_, 'requestId': rid,
                                                    'requestData': data or {}}}))
        # OBS sender ogsaa hendelser (op 5) uoppfordret. Vi leser til VAART svar dukker opp.
        for _ in range(20):
            m = json.loads(self._recv_frame())
            if m.get('op') == 7 and m['d'].get('requestId') == rid:
                d = m['d']
                st = d.get('requestStatus', {})
                if not st.get('result'):
                    raise ObsFeil('%s: %s' % (type_, st.get('comment') or st.get('code')))
                return d
        raise ConnectionError('OBS svarte ikke paa %s' % type_)

    # ---- det agenten faktisk bruker ----
    def scener(self):
        d = self._kall('GetSceneList')
        return [s['sceneName'] for s in d.get('responseData', {}).get('scenes', [])]

    def naavaerende_scene(self):
        r = self._kall('GetCurrentProgramScene').get('responseData', {})
        return r.get('currentProgramSceneName') or r.get('sceneName')

    def bytt(self, scene):
        self._kall('SetCurrentProgramScene', {'sceneName': scene})
        return True

    def lag_scene(self, navn):
        return self._kall('CreateScene', {'sceneName': navn})

    def sender(self):
        return bool(self._kall('GetStreamStatus').get('responseData', {}).get('outputActive'))

    def start_sending(self):
        return self._kall('StartStream')

    def stopp_sending(self):
        return self._kall('StopStream')

    def stroeminnstillinger(self):
        return self._kall('GetStreamServiceSettings').get('responseData', {})

    # ---- opptak i OBS (lokalt paa maskinen din) ----
    def opptak(self):
        return bool(self._kall('GetRecordStatus').get('responseData', {}).get('outputActive'))

    def start_opptak(self):
        return self._kall('StartRecord')

    def stopp_opptak(self):
        return self._kall('StopRecord')

    def sett_stroeminnstillinger(self, server, nokkel=''):
        # rtmp_custom er OBS sin "Egendefinert"-tjeneste. Den tar imot SRT- og RIST-adresser
        # like godt som RTMP - feltet heter bare "server" av historiske grunner.
        return self._kall('SetStreamServiceSettings', {
            'streamServiceType': 'rtmp_custom',
            'streamServiceSettings': {'server': server, 'key': nokkel or '',
                                      'use_auth': False},
        })

    def lukk(self):
        try:
            if self.sock:
                self.sock.close()
        except OSError:
            pass
        self.sock = None


class ObsFeil(Exception):
    """OBS svarte, men sa nei. Skilles fra ConnectionError: her er forbindelsen i orden."""


# --------------------------------------------------------------------------- motorene
class Motor:
    """Felles form. Alt fjernstyringen trenger aa vite er: sender du, start, stopp."""

    navn = 'ukjent'
    kan_scener = False

    def klar(self):
        return True

    def sender(self):
        return False

    def start(self, publisering):
        raise NotImplementedError

    def stopp(self):
        raise NotImplementedError

    def puls(self, tilstand):
        """Kalles hver runde. Her hoerer alt som skal holdes i synk hjemme."""

    def sikre_scener(self, navn):
        """Bare motorer med scener gjoer noe her."""

    def bytt_scene(self, scene):
        """Bare motorer med scener gjoer noe her."""

    def lag_scene(self, navn):
        """Bare motorer med scener gjoer noe her."""

    def start_opptak(self):
        """Bare motorer med OBS gjoer noe her."""

    def stopp_opptak(self):
        """Bare motorer med OBS gjoer noe her."""

    def tar_opp(self):
        """Tar OBS opp lokalt? None naar motoren ikke har opptak i det hele tatt."""
        return None

    def rapport(self):
        return {}

    def lukk(self):
        pass


class ObsMotor(Motor):
    """OBS paa denne maskinen, styrt over obs-websocket."""

    navn = 'obs'
    kan_scener = True

    def __init__(self, c):
        self.c = c
        self.ws = None
        self.sist_srt = None        # sist innsatte server-URL, saa vi ikke skriver i ett kjoer
        self.scene_naa = None
        self.scener_sett = []
        self.sist_feil = None

    def _ws(self):
        if self.ws:
            return self.ws
        w = ObsWs(self.c.get('obs_host'), self.c.get('obs_port'), self.c.get('obs_passord'))
        w.koble()
        self.ws = w
        self.sist_feil = None
        # Ny forbindelse: vi vet ikke lenger hva som staar i OBS. Tving en ny sjekk av SRT-lenka.
        self.sist_srt = None
        logg('koblet til OBS paa %s:%s' % (self.c.get('obs_host'), self.c.get('obs_port')))
        return w

    def _mist(self, e):
        """Forbindelsen roek. Rydd, saa neste runde kobler paa nytt i stedet for aa henge."""
        if self.ws:
            self.ws.lukk()
        self.ws = None
        tekst = str(e)[:120]
        if tekst != self.sist_feil:
            # Bare foerste gang: OBS som er lukket gir ellers en linje hvert 2. sekund i loggen.
            logg('mistet OBS:', tekst)
            self.sist_feil = tekst

    def klar(self):
        try:
            self._ws()
            return True
        except (OSError, ConnectionError, ValueError) as e:
            self._mist(e)
            return False

    def sender(self):
        try:
            return self._ws().sender()
        except (OSError, ConnectionError, ValueError, ObsFeil) as e:
            self._mist(e)
            return False

    def start(self, publisering):
        w = self._ws()
        # Sett lenka FOER vi starter: starter OBS mot en gammel eller tom adresse, ser det ut
        # som en nettverksfeil hos oss, og kunden leter helt feil sted.
        self._sett_srt(w, publisering)
        if not w.sender():
            w.start_sending()
            logg('startet sendingen i OBS')

    def stopp(self):
        w = self._ws()
        if w.sender():
            w.stopp_sending()
            logg('stoppet sendingen i OBS')

    def _sett_srt(self, w, publisering):
        """Fyller inn SRT-lenka di i OBS.

        Dette er "vaart oppsett": du limer ingenting inn selv, og bytter du noekler i portalen,
        er den nye lenka paa plass ved neste runde. Vi skriver bare naar noe faktisk er
        annerledes - OBS avviser endringer mens sendingen gaar, og en blind skriving hver runde
        ville dessuten skrevet konfigfila til OBS i stykker over tid.
        """
        if not publisering or not self.c.get('obs_sett_srt', True):
            return
        srt = publisering.get('srt')
        sid = publisering.get('streamid')
        if not srt or not sid:
            return
        onsket = '%s?streamid=%s' % (srt, sid)
        if onsket == self.sist_srt:
            return
        try:
            naa = w.stroeminnstillinger()
            if (naa.get('streamServiceSettings') or {}).get('server') == onsket:
                self.sist_srt = onsket
                return
            if w.sender():
                # Midt i en sending: OBS avviser endringen uansett. Vi proever igjen naar
                # sendingen er stoppet - og sier fra, for dette er nettopp tilfellet der en
                # nyrotert noekkel ikke har slaatt gjennom enda.
                logg('ny SRT-lenke venter - settes inn naar sendingen stoppes')
                return
            w.sett_stroeminnstillinger(onsket, '')
            self.sist_srt = onsket
            logg('satte SRT-lenka i OBS (Innstillinger -> Stream)')
        except ObsFeil as e:
            logg('kunne ikke sette SRT-lenka:', str(e)[:100])

    def puls(self, tilstand):
        try:
            w = self._ws()
            self._sett_srt(w, tilstand.get('publisering'))
            self.scener_sett = w.scener()
            self.scene_naa = w.naavaerende_scene()
        except (OSError, ConnectionError, ValueError, ObsFeil) as e:
            self._mist(e)

    def lag_scene(self, navn):
        self._ws().lag_scene(navn)
        self.scener_sett = self._ws().scener()

    def start_opptak(self):
        self._ws().start_opptak()

    def stopp_opptak(self):
        self._ws().stopp_opptak()

    def sikre_scener(self, navn):
        """Lager scenene automatikken trenger, hvis de mangler.

        Vi skriver ALDRI scenesamlinger som JSON-fil: OBS forkaster i stillhet en samling den
        ikke kjenner igjen og faller tilbake til forrige - alt ser normalt ut mens ingenting
        skjedde. CreateScene gaar gjennom OBS sitt eget API og kan ikke bomme paa formatet.
        """
        try:
            w = self._ws()
            finnes = set(w.scener())
            for n in navn:
                if n and n not in finnes:
                    w.lag_scene(n)
                    logg('opprettet scene "%s" i OBS' % n)
            self.scener_sett = w.scener()
        except (OSError, ConnectionError, ValueError, ObsFeil) as e:
            self._mist(e)

    def bytt_scene(self, scene):
        self._ws().bytt(scene)
        self.scene_naa = scene

    def scener(self):
        """Kun for `oppsett`-kommandoen: vis kunden at vi faktisk ser scenene hens."""
        return self._ws().scener()

    def tar_opp(self):
        try:
            return self._ws().opptak()
        except (OSError, ConnectionError, ValueError, ObsFeil) as e:
            self._mist(e)
            return None

    def rapport(self):
        return {'scener': self.scener_sett, 'scene': self.scene_naa}

    def lukk(self):
        if self.ws:
            self.ws.lukk()
        self.ws = None


class FfmpegMotor(Motor):
    """En kommando som koder og sender - ffmpeg, belacoder eller ditt eget skript."""

    navn = 'ffmpeg'
    kan_scener = False

    # Et fornuftig utgangspunkt for et USB-kamera. Fungerer det ikke paa nettopp din maskin,
    # setter du din egen med: streamsky-agent.py oppsett --kommando "..."
    STANDARD_LINUX = ('ffmpeg -hide_banner -loglevel warning '
                      '-f v4l2 -input_format mjpeg -video_size 1280x720 -i /dev/video0 '
                      '-f alsa -i default -c:v libx264 -preset veryfast -b:v 4000k '
                      '-c:a aac -b:a 128k -f mpegts {url}')
    STANDARD_WINDOWS = ('ffmpeg -hide_banner -loglevel warning '
                        '-f dshow -i video="{kamera}":audio="{mikrofon}" '
                        '-c:v libx264 -preset veryfast -b:v 4000k '
                        '-c:a aac -b:a 128k -f mpegts {url}')

    # Doer senderen med en gang, er kommandoen som regel feil - ikke nettet. Uten disse to ville
    # agenten startet den paa nytt hvert 2. sekund i det uendelige: hundrevis av prosesser i
    # minuttet paa kundens maskin, og en logg det er umulig aa finne aarsaken i.
    KJAPP_DOED_SEK = 5
    FORSOK_FOER_PAUSE = 3
    PAUSE_SEK = 60

    def __init__(self, c):
        self.c = c
        self.proc = None
        self.startet = 0
        self.feil = 0
        self.neste_forsok = 0
        self.har_klaget = False

    def klar(self):
        return bool(self.c.get('ffmpeg_kommando'))

    def sender(self):
        if self.proc is None:
            return False
        if self.proc.poll() is None:
            # Har den staatt lenge nok, var forrige feilrekke et blaff. Nullstill, ellers ville
            # tre gamle feil fra i gaar fortsatt bremset en helt frisk sending i dag.
            if self.startet and time.time() - self.startet >= self.KJAPP_DOED_SEK and self.feil:
                self.feil = 0
                self.har_klaget = False
            return True
        # Prosessen er borte. Var den kortlevd, teller vi det som et mislykket forsoek.
        if self.startet and time.time() - self.startet < self.KJAPP_DOED_SEK:
            self.feil += 1
            if self.feil >= self.FORSOK_FOER_PAUSE:
                self.neste_forsok = time.time() + self.PAUSE_SEK
                if not self.har_klaget:
                    logg('senderen avsluttet med en gang %d ganger paa rad (kode %s). '
                         'Sjekk kommandoen din med: streamsky-agent.py oppsett --kommando "..."'
                         % (self.feil, self.proc.returncode))
                    logg('proever igjen om %d sekunder.' % self.PAUSE_SEK)
                    self.har_klaget = True
        self.proc = None
        self.startet = 0
        return False

    def _argv(self, publisering):
        url = '%s?streamid=%s' % (publisering.get('srt'), publisering.get('streamid'))
        mal = self.c.get('ffmpeg_kommando') or ''
        if '{url}' not in mal:
            # Uten plassholder ville URL-en aldri kommet med, og ffmpeg hadde sendt til ingenting.
            mal = mal + ' {url}'
        # posix=False, og sitattegnene fjernes etterpaa av oss selv.
        # posix=True spiser backslash: en Windows-sti som C:\Users\meg\ffmpeg.exe kommer ut som
        # C:Usersmegffmpeg.exe. posix=False bevarer stien, men beholder sitattegnene INNE i
        # tokenet, saa `-c "import sys"` ble sendt videre med anfoerselstegn og programmet
        # avviste argumentet. Vi trenger begge deler: del som Windows, fjern hylsteret selv.
        biter = shlex.split(mal, posix=False)
        ut = []
        for b in biter:
            if len(b) >= 2 and b[0] == b[-1] and b[0] in '"\'':
                b = b[1:-1]
            ut.append(b.replace('{url}', url))
        return ut

    def start(self, publisering):
        if self.sender() or not publisering:
            return
        if time.time() < self.neste_forsok:
            return
        argv = self._argv(publisering)
        logg('starter senderen:', argv[0])
        try:
            self.proc = subprocess.Popen(argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        except OSError as e:
            # Programmet finnes ikke. Ingen vits i aa proeve igjen med det samme.
            self.feil += 1
            self.neste_forsok = time.time() + self.PAUSE_SEK
            if not self.har_klaget:
                logg('fikk ikke startet "%s": %s' % (argv[0], e))
                self.har_klaget = True
            return
        self.startet = time.time()

    def stopp(self):
        # Kunden har selv bedt om stopp: da er ikke feilrekka relevant lenger, og neste start
        # skal skje med en gang - ikke etter en pause satt av gamle feil.
        self.feil = 0
        self.neste_forsok = 0
        self.har_klaget = False
        if not self.sender():
            self.proc = None
            return
        logg('stopper senderen')
        self.proc.terminate()
        try:
            self.proc.wait(timeout=8)
        except subprocess.TimeoutExpired:
            self.proc.kill()
        self.proc = None
        self.startet = 0

    def lukk(self):
        self.stopp()


def lag_motor(c):
    valgt = str(c.get('motor') or 'obs').lower()
    if valgt == 'ffmpeg':
        return FfmpegMotor(c)
    return ObsMotor(c)


# --------------------------------------------------------------------------- telemetri
_forrige_tall = {}


def temperatur():
    try:
        with open('/sys/class/thermal/thermal_zone0/temp') as f:
            return round(int(f.read().strip()) / 1000.0, 1)
    except (OSError, ValueError):
        return None


def lenker():
    """Nettlinjene maskinen sender paa, med maalt fart siden forrige runde.

    Vises i fjernstyringen, saa du ser paa mobilen hvilket modem som faktisk baerer. Kun Linux:
    /proc/net/dev finnes ikke paa Windows, og en mini-PC paa kablet nett har uansett bare en.
    """
    ut = []
    naa = time.time()
    try:
        with open('/proc/net/dev') as f:
            linjer = f.readlines()[2:]
    except OSError:
        return ut
    for linje in linjer:
        navn, _, rest = linje.partition(':')
        navn = navn.strip()
        if not navn.startswith(('wwan', 'usb', 'eth', 'wlan', 'enx', 'en')):
            continue
        felt = rest.split()
        if len(felt) < 9:
            continue
        sendt = int(felt[8])
        forrige = _forrige_tall.get(navn)
        _forrige_tall[navn] = (sendt, naa)
        kbps = 0
        if forrige and naa > forrige[1]:
            kbps = max(0, round((sendt - forrige[0]) * 8 / (naa - forrige[1]) / 1000, 1))
        oppe = False
        try:
            with open('/sys/class/net/%s/operstate' % navn) as f:
                oppe = f.read().strip() == 'up'
        except OSError:
            pass
        ut.append({'navn': navn, 'oppe': oppe, 'kbps': kbps})
    return ut[:8]


# --------------------------------------------------------------------------- scenebytte
STANDARD_TERSKLER = {'offline': 0, 'low': 800, 'rttOffline': 5000, 'rtt': 2500}


def _tall(v, standard=None):
    """Tall fra API-et, uansett hvordan de kommer.

    JSON kan gi tall som streng, som null, eller som NaN naar maalingen er oedelagt. NaN er den
    farlige: alle sammenligninger med NaN er False, saa en oedelagt maaling ville havnet i
    "alt er bra" og fortalt oss at sendingen gikk fint mens den var borte.
    """
    if v is None or isinstance(v, bool):
        return standard
    try:
        f = float(v)
    except (TypeError, ValueError):
        return standard
    if f != f or f in (float('inf'), float('-inf')):
        return standard
    return f


def onsket_scene_tilstand(m, c):
    """Samme regel som paa vaare egne bokser: RTT ELLER bitrate kan utloese bytte."""
    if not m.get('live'):
        return 'offline'
    t = {k: _tall(c.get(k), v) for k, v in STANDARD_TERSKLER.items()}
    kbps = _tall(m.get('kbps'))
    rtt = _tall(m.get('rttMs'))
    if kbps is None:
        # Ubrukelig maaling behandles som "borte", ikke som "bra": feiler vi, skal vi feile mot
        # pauseskjermen - ikke mot et frosset bilde seerne blir sittende med.
        return 'offline'
    if kbps <= t['offline'] or (rtt is not None and rtt >= t['rttOffline']):
        return 'offline'
    if kbps <= t['low'] or (rtt is not None and rtt >= t['rtt']):
        return 'low'
    return 'normal'


# --------------------------------------------------------------------------- kommandoer
def cmd_par(args):
    c = les_konfig()
    if args.base:
        c['base'] = args.base
    kropp = {
        'kode': args.kode.strip().upper(),
        'navn': args.navn or platform.node() or 'Min maskin',
        'modell': maskinmodell(),
        'versjon': VERSJON,
        # Plattformen bruker dette til aa vise riktige knapper: en ffmpeg-maskin har ingen
        # scener aa bytte mellom, og skal ikke faa et scenevelger-panel som ikke gjoer noe.
        'motor': c.get('motor', 'obs'),
        'kanScener': lag_motor(c).kan_scener,
    }
    try:
        svar = kall(c, '/api/enhet/par', kropp)
    except urllib.error.HTTPError as e:
        detalj = ''
        try:
            detalj = json.loads(e.read().decode('utf-8') or '{}').get('error', '')
        except (ValueError, OSError):
            pass
        sys.exit('Pareringen feilet (HTTP %s). %s' % (e.code, detalj))
    except OSError as e:
        sys.exit('Fikk ikke kontakt med %s: %s' % (c.get('base'), e))
    c.update({'token': svar['token'], 'stream': svar['stream'],
              'srt': svar.get('srt'), 'srtla': svar.get('srtla')})
    skriv_konfig(c)
    print('Paret mot stroemmen "%s".' % svar['stream'])
    print('Konfig lagret i %s' % KONFIG)
    print('Neste: %s oppsett   (velg motor og test OBS)' % os.path.basename(sys.argv[0]))


def maskinmodell():
    try:
        with open('/proc/device-tree/model', 'rb') as f:
            return f.read().decode('utf-8', 'replace').strip('\x00').strip()[:60]
    except OSError:
        pass
    return ('%s %s' % (platform.system(), platform.release()))[:60]


def cmd_oppsett(args):
    c = les_konfig()
    if args.motor:
        c['motor'] = args.motor
    if args.obs_host:
        c['obs_host'] = args.obs_host
    if args.obs_port:
        c['obs_port'] = int(args.obs_port)
    if args.obs_passord is not None:
        c['obs_passord'] = args.obs_passord
    if args.kommando:
        c['ffmpeg_kommando'] = args.kommando
    if args.ikke_sett_srt:
        c['obs_sett_srt'] = False
    if c.get('motor') == 'ffmpeg' and not c.get('ffmpeg_kommando'):
        c['ffmpeg_kommando'] = (FfmpegMotor.STANDARD_WINDOWS if os.name == 'nt'
                                else FfmpegMotor.STANDARD_LINUX)
        print('Ingen kommando satt - la inn et utgangspunkt. Tilpass den med --kommando.')
    skriv_konfig(c)
    print('Motor: %s' % c.get('motor'))
    if c.get('motor') == 'obs':
        print('OBS:   %s:%s' % (c.get('obs_host'), c.get('obs_port')))
        m = ObsMotor(c)
        if m.klar():
            print('       kontakt OK - scener: %s' % ', '.join(m.scener()[:8] or ['(ingen)']))
            print('       SRT-lenka fylles inn automatisk: %s'
                  % ('ja' if c.get('obs_sett_srt', True) else 'nei (du styrer den selv)'))
            m.lukk()
        else:
            print('       FANT IKKE OBS. Sjekk at OBS kjoerer, og at')
            print('       Verktoey -> WebSocket Server Settings -> Enable WebSocket server er paa.')
            print('       Staar "Enable Authentication" paa, oppgi passordet med --obs-passord.')
    else:
        print('Kommando: %s' % (c.get('ffmpeg_kommando') or '(ikke satt)'))
    print('Konfig: %s' % KONFIG)


def cmd_status(args):
    c = les_konfig()
    print('Konfig:  %s' % KONFIG)
    print('Paret:   %s' % ('ja, stroem "%s"' % c.get('stream') if c.get('token') else 'NEI'))
    print('Motor:   %s' % c.get('motor'))
    if not c.get('token'):
        return
    try:
        t = kall(c, '/api/agent/state', token=c['token'])
    except urllib.error.HTTPError as e:
        print('Tilstand: HTTP %s%s' % (e.code, ' - tokenet er ikke lenger gyldig, par paa nytt'
                                       if e.code == 401 else ''))
        return
    except OSError as e:
        print('Tilstand: ingen kontakt (%s)' % e)
        return
    print('Abonnement: %s' % ('aktivt' if t.get('enabled') or t.get('paired') else 'inaktivt'))
    k = t.get('kontroll') or {}
    print('Oensket:  sending=%s hovedscene=%s' % (k.get('sending') or '-', k.get('hovedscene') or '-'))
    m = lag_motor(c)
    print('Sender naa: %s' % ('ja' if m.sender() else 'nei'))
    m.lukk()


def cmd_koble_fra(args):
    c = les_konfig()
    if not c.get('token'):
        sys.exit('Ikke paret.')
    try:
        kall(c, '/api/agent/koble-fra', {}, token=c['token'])
        print('Meldt av. Maskinen vises ikke lenger i portalen.')
    except urllib.error.HTTPError as e:
        print('Avmeldingen feilet (HTTP %s) - fjerner uansett tokenet lokalt.' % e.code)
    except OSError as e:
        print('Ingen kontakt (%s) - fjerner uansett tokenet lokalt.' % e)
    for n in ('token', 'stream'):
        c.pop(n, None)
    skriv_konfig(c)


SYSTEMD = """[Unit]
Description=Stream Sky agent
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=%(bruker)s
Environment=PYTHONUNBUFFERED=1
ExecStart=%(python)s %(skript)s kjor
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
"""


def cmd_tjeneste(args):
    """Skriver ut - eller installerer - oppsettet som starter agenten ved oppstart."""
    skript = os.path.abspath(sys.argv[0])
    if os.name == 'nt':
        oppgave = ('schtasks /Create /TN "Stream Sky agent" /SC ONLOGON /RL LIMITED '
                   '/TR "\\"%s\\" \\"%s\\" kjor" /F' % (sys.executable, skript))
        print('Windows: kjoer denne i et vanlig kommandovindu for aa starte agenten ved paalogging:')
        print()
        print('  ' + oppgave)
        print()
        print('Fjern den igjen med:  schtasks /Delete /TN "Stream Sky agent" /F')
        print('Vi kjoerer den ikke for deg - en oppgave som starter automatisk boer du')
        print('opprette bevisst selv.')
        return
    enhet = SYSTEMD % {'bruker': os.environ.get('SUDO_USER') or os.environ.get('USER') or 'root',
                       'python': sys.executable, 'skript': skript}
    if not args.installer:
        print(enhet)
        print('# Lagre som /etc/systemd/system/streamsky-agent.service, eller kjoer')
        print('# denne kommandoen med sudo:  %s tjeneste --installer' % os.path.basename(skript))
        return
    sti = '/etc/systemd/system/streamsky-agent.service'
    try:
        with open(sti, 'w', encoding='utf-8') as f:
            f.write(enhet)
    except PermissionError:
        sys.exit('Trenger root for aa skrive %s. Proev med sudo.' % sti)
    subprocess.run(['systemctl', 'daemon-reload'], check=False)
    subprocess.run(['systemctl', 'enable', '--now', 'streamsky-agent'], check=False)
    print('Installert og startet. Logg: journalctl -u streamsky-agent -f')


# --------------------------------------------------------------------------- hovedsloeyfe
def cmd_kjor(args):
    c = les_konfig()
    if not c.get('token'):
        sys.exit('Ikke paret. Kjoer: %s par SSKY-XXXX-XXXX-XXXX'
                 % os.path.basename(sys.argv[0]))
    token = c['token']
    motor = lag_motor(c)
    if not motor.klar() and motor.navn == 'ffmpeg':
        sys.exit('Ingen sender-kommando satt. Kjoer: %s oppsett --kommando "..."'
                 % os.path.basename(sys.argv[0]))

    sist_nonce = None
    sist_scene_onske = None
    sist_sender = None        # None = ikke rapportert enda, saa foerste runde alltid melder fra
    runde = 0
    advart_versjon = False
    stille_til = 0        # ved gjentatte nettfeil: ikke hamre, og ikke fylle loggen

    logg('Stream Sky agent %s - stroem "%s", motor %s' % (VERSJON, c.get('stream'), motor.navn))
    logg('konfig: %s' % KONFIG)
    try:
        while True:
            runde += 1
            try:
                t = kall(c, '/api/agent/state', token=token)
                stille_til = 0
            except urllib.error.HTTPError as e:
                if e.code == 401:
                    # Tokenet er doedt - du har paret en annen maskin, eller meldt denne av.
                    # Da er det riktige aa stoppe sendingen og vente, ikke aa hamre videre.
                    logg('tokenet er ikke lenger gyldig. Stopper og venter. Par paa nytt.')
                    motor.stopp()
                    time.sleep(60)
                    continue
                if e.code == 429:
                    time.sleep(30)
                    continue
                logg('tilstand feilet: HTTP %s' % e.code)
                time.sleep(POLL_SEK * 3)
                continue
            except (OSError, ValueError) as e:
                # Nett nede, DNS, tidsavbrudd. Sendingen din gaar direkte til inntaket vaart og
                # er UBEROERT av dette - det er bare fjernstyringen som er ute et oeyeblikk.
                if time.time() > stille_til:
                    logg('naar ikke %s: %s' % (c.get('base'), str(e)[:90]))
                    stille_til = time.time() + 60
                time.sleep(POLL_SEK * 3)
                continue

            if t.get('apiVersjon') and t['apiVersjon'] != API_VERSJON_BYGD_FOR and not advart_versjon:
                logg('ADVARSEL: enhets-API er versjon %s, denne agenten er bygd for %s. '
                     'Hent en ny agent fra streamsky.no.'
                     % (t.get('apiVersjon'), API_VERSJON_BYGD_FOR))
                advart_versjon = True

            if not t.get('paired', True):
                logg('stroemmen er borte fra kontoen. Venter.')
                motor.stopp()
                time.sleep(30)
                continue

            pub = t.get('publisering') or {}
            k = t.get('kontroll') or {}
            kvittering = None

            # ---- 1. Engangshandling foerst -------------------------------------------------
            # Ellers ville tilstands-sjekken under spist en restart: oensket er "paa", vi sender
            # allerede, altsaa "ingenting aa gjoere" - og restarten forsvant i stillhet.
            h = k.get('handling')
            if isinstance(h, dict) and h.get('nonce') and h['nonce'] != sist_nonce:
                sist_nonce = h['nonce']
                kvittering = h['nonce']
                type_ = h.get('type')
                # OBS-styring fra panelet. En koder-maskin har ingenting av dette, saa den
                # kvitterer bare ut handlingen - ellers ville panelet staatt og ventet.
                if type_ in ('scene', 'lag-scene', 'opptak-start', 'opptak-stopp'):
                    if motor.kan_scener:
                        try:
                            if type_ == 'scene':
                                motor.bytt_scene(h.get('scene'))
                                logg('byttet til scene "%s" (fra panelet)' % h.get('scene'))
                            elif type_ == 'lag-scene':
                                motor.lag_scene(h.get('scene'))
                                logg('opprettet scene "%s" (fra panelet)' % h.get('scene'))
                            elif type_ == 'opptak-start':
                                motor.start_opptak()
                                # OBS river opp opptaket asynkront - leser vi status med en
                                # gang, melder vi "tar ikke opp" rett etter et vellykket start.
                                time.sleep(1.5)
                                logg('startet opptak i OBS (fra panelet)')
                            else:
                                motor.stopp_opptak()
                                time.sleep(1.5)
                                logg('stoppet opptak i OBS (fra panelet)')
                        except (OSError, ConnectionError, ObsFeil) as e:
                            logg('OBS-styring (%s) feilet:' % type_, str(e)[:110])
                    else:
                        logg('hopper over "%s" - denne maskinen har ingen OBS' % type_)
                elif h.get('type') == 'restart':
                    logg('restart bedt om')
                    try:
                        motor.stopp()
                        time.sleep(2)      # gi utgangen et oeyeblikk paa aa rive seg ned
                        motor.start(pub)
                    except (OSError, ConnectionError, ObsFeil) as e:
                        logg('restart feilet:', str(e)[:100])

            # ---- 2. Deretter tilstanden: sammenlign og rett opp ----------------------------
            else:
                onsket = k.get('sending')
                try:
                    if onsket == 'paa':
                        if not motor.sender():
                            motor.start(pub)
                    elif onsket == 'av' and motor.sender():
                        motor.stopp()
                except (OSError, ConnectionError, ObsFeil) as e:
                    logg('kunne ikke %s: %s' % ('starte' if onsket == 'paa' else 'stoppe',
                                                str(e)[:100]))

            # ---- 3. Hold OBS i synk (SRT-lenke, scenelista) --------------------------------
            motor.puls({'publisering': pub})

            # ---- 4. Automatisk scenebytte --------------------------------------------------
            konf = t.get('config') or {}
            if motor.kan_scener and t.get('enabled') and konf.get('enabled'):
                scener = konf.get('scenes') or {}
                onsket_scene = scener.get(onsket_scene_tilstand(t, konf))
                if onsket_scene and onsket_scene != sist_scene_onske:
                    try:
                        motor.sikre_scener([scener.get('normal'), scener.get('low'),
                                            scener.get('offline')])
                        naa = motor.scene_naa
                        # Staar du paa en scene automatikken ikke kjenner, roerer vi den ikke.
                        # Du kan ha byttet til noe med vilje midt i sendingen.
                        if naa in (scener.get('normal'), scener.get('low'), scener.get('offline')):
                            if naa != onsket_scene:
                                motor.bytt_scene(onsket_scene)
                                logg('byttet scene til "%s"' % onsket_scene)
                        sist_scene_onske = onsket_scene
                    except (OSError, ConnectionError, ObsFeil) as e:
                        logg('scenebytte feilet:', str(e)[:100])

            # ---- 5. Meld tilbake -----------------------------------------------------------
            # Rapporter MED EN GANG naar sende-tilstanden endrer seg, ikke bare paa den periodiske
            # runden. Uten dette sto kontrollsiden med "Sender: nei" i opptil 20 sekunder etter at
            # kunden hadde trykket Start - og det leses som at knappen ikke virket.
            sender_naa = motor.sender()
            endret = sender_naa != sist_sender
            sist_sender = sender_naa
            if kvittering or endret or runde % RAPPORT_HVER == 1:
                # Motoren meldes hver gang, ikke bare ved parering: bytter du fra ffmpeg til OBS
                # paa samme maskin, dukker scenevelgeren opp i portalen uten at du parer paa nytt.
                data = {'sender': sender_naa, 'kvittering': kvittering,
                        'motor': motor.navn, 'kanScener': motor.kan_scener,
                        'opptak': motor.tar_opp(),
                        'enhet': {'versjon': VERSJON, 'tempC': temperatur(), 'lenker': lenker()}}
                data.update(motor.rapport())
                try:
                    kall(c, '/api/agent/rapport', data, token=token)
                except (OSError, ValueError, urllib.error.HTTPError) as e:
                    logg('rapport feilet:', str(e)[:80])

            time.sleep(POLL_SEK)
    finally:
        motor.lukk()


# --------------------------------------------------------------------------- kommandolinje
def main():
    p = argparse.ArgumentParser(
        prog='streamsky-agent',
        description='Stream Sky - fjernstyring av din egen sendemaskin.',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog='Full veiledning: https://streamsky.no/egen-maskin')
    u = p.add_subparsers(dest='kommando')

    a = u.add_parser('par', help='koble maskinen til kontoen din med en engangskode')
    a.add_argument('kode', help='parekoden fra portalen, f.eks. SSKY-ABCD-EFGH-JKLM')
    a.add_argument('--navn', help='navnet maskinen skal vises med i portalen')
    a.add_argument('--base', help='alternativ panel-adresse (for testing)')
    a.set_defaults(func=cmd_par)

    a = u.add_parser('oppsett', help='velg motor og test forbindelsen')
    a.add_argument('--motor', choices=['obs', 'ffmpeg'])
    a.add_argument('--obs-host', dest='obs_host')
    a.add_argument('--obs-port', dest='obs_port')
    a.add_argument('--obs-passord', dest='obs_passord',
                   help='passordet fra OBS -> Verktoey -> WebSocket Server Settings')
    a.add_argument('--kommando', help='sender-kommando for motor=ffmpeg ({url} settes inn)')
    a.add_argument('--ikke-sett-srt', dest='ikke_sett_srt', action='store_true',
                   help='ikke fyll inn SRT-lenka i OBS - du styrer stroeminnstillingene selv')
    a.set_defaults(func=cmd_oppsett)

    a = u.add_parser('kjor', help='kjoer agenten (dette er den som lytter paa fjernstyringen)')
    a.set_defaults(func=cmd_kjor)

    a = u.add_parser('status', help='vis oppsett og hva plattformen oensker akkurat naa')
    a.set_defaults(func=cmd_status)

    a = u.add_parser('koble-fra', help='meld maskinen av kontoen')
    a.set_defaults(func=cmd_koble_fra)

    a = u.add_parser('tjeneste', help='start agenten automatisk ved oppstart')
    a.add_argument('--installer', action='store_true', help='Linux: skriv og aktiver systemd-enheten')
    a.set_defaults(func=cmd_tjeneste)

    args = p.parse_args()
    if not getattr(args, 'func', None):
        p.print_help()
        return
    args.func(args)


if __name__ == '__main__':
    try:
        main()
    except KeyboardInterrupt:
        pass
