#!/usr/bin/env python3
"""Der Sammler: liest auf deinem Rechner, wie viele Tokens verbraucht wurden, und schickt
die Summen an deinen Tokagotchi.

Warum es dieses Programm ueberhaupt gibt: die Zahlen liegen als Dateien auf dem Rechner, an
dem gearbeitet wird. Claude Code, Codex und Gemini schreiben ihre Sitzungen dorthin, und
dort steht auch, wie viele Tokens eine Antwort gekostet hat. Ein Server im Rechenzentrum
kommt an diese Dateien nicht heran - deshalb liest dieses Programm sie vor Ort und schickt
nur die Summen weiter. Denselben Weg geht vibeTV mit CodexBar auf dem Mac.

**Was hinausgeht, sind Zahlen.** Aus jeder Zeile werden ausschliesslich Verbrauchswerte,
Zeitstempel, Modellname und Arbeitsverzeichnis genommen. Gespraechsinhalte verlassen die
Datei nie. Wer auch die Projektnamen fuer sich behalten will, nimmt `--ohne-projekte`.

Eine Datei, keine Fremdbibliothek, laeuft unter Linux, macOS und Windows mit Python 3.9
oder neuer. Es gibt nichts zu installieren.

    python3 sammler.py --einrichten https://tokagotchi.de DEIN-SCHLUESSEL
    python3 sammler.py                  # laeuft und schickt alle 60 Sekunden
    python3 sammler.py --einmal         # einmal schicken und beenden
    python3 sammler.py --probe          # nur anzeigen, was gefunden wurde

`--einrichten` legt die Einstellungen an und richtet den Autostart ein: einen
systemd-Nutzerdienst unter Linux, einen launchd-Agenten unter macOS, eine Aufgabe in der
Aufgabenplanung unter Windows.

Den Schluessel bekommst du auf der Einstellseite deines Tokagotchi.
"""

import argparse
import json
import os
import platform
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from collections import defaultdict
from datetime import datetime, timedelta, timezone

# Wo die drei Werkzeuge ihre Sitzungen ablegen. Dieselben Pfade auf allen drei Systemen -
# unter Windows loest `expanduser` nach C:\Users\<name> auf.
NAEPFE = {
    "claude": {"titel": "CLAUDE", "pfad": "~/.claude/projects"},
    "codex": {"titel": "CODEX", "pfad": "~/.codex/sessions"},
    "gemini": {"titel": "GEMINI", "pfad": "~/.gemini/tmp"},
}

# Laenge des ausgelieferten Verlaufs, in Fuenf-Minuten-Eimern. 288 = 24 Stunden.
EIMER_S = 300
EIMER_ANZAHL = 288
# Aelter als das interessiert niemanden mehr; begrenzt zugleich den Speicher.
GEDAECHTNIS_TAGE = 8
# Wie tief im Baum eines Eintrags nach einem Verbrauchsblock gesucht wird. Sitzungsformate
# schachteln gern zwei bis drei Ebenen; darunter wird es nur noch teuer.
TIEFE = 6

# ======================================================================================
# Der Erkenner. Dieselbe Fassung benutzt `server/futterdienst.py`, der ihn von hier
# importiert - zwei Kopien wuerden bei der naechsten Formataenderung auseinanderlaufen.
# ======================================================================================

def _jetzt():
    return datetime.now(timezone.utc)


def _zeitstempel(wert):
    """Aus dem, was ein Eintrag als Zeit fuehrt, ein datetime machen. Sonst None."""
    if isinstance(wert, (int, float)):
        # Millisekunden kommen in JavaScript-naher Software haeufig vor.
        zahl = float(wert)
        if zahl > 1e11:
            zahl /= 1000.0
        try:
            return datetime.fromtimestamp(zahl, timezone.utc)
        except (OSError, OverflowError, ValueError):
            return None
    if not isinstance(wert, str) or not wert:
        return None
    try:
        gelesen = datetime.fromisoformat(wert.replace("Z", "+00:00"))
    except ValueError:
        return None
    return gelesen if gelesen.tzinfo else gelesen.replace(tzinfo=timezone.utc)


# -- Der Erkenner ------------------------------------------------------------------------

def verbrauch_lesen(block):
    """Aus einem Verbrauchsblock (ein, aus, cache_neu, cache_gelesen) machen.

    Drei Schreibweisen, weil drei Hersteller. Was keiner davon entspricht, gibt None zurueck
    und wird uebergangen - lieber eine Zahl nicht zaehlen als eine erfinden.
    """
    if not isinstance(block, dict):
        return None

    # Anthropic / Claude Code
    if "input_tokens" in block or "output_tokens" in block:
        return (
            int(block.get("input_tokens") or 0),
            int(block.get("output_tokens") or 0)
            + int(block.get("reasoning_output_tokens") or 0),
            int(block.get("cache_creation_input_tokens") or 0),
            int(block.get("cache_read_input_tokens") or 0)
            + int(block.get("cached_input_tokens") or 0),
        )

    # OpenAI / Codex in der klassischen Schreibweise
    if "prompt_tokens" in block or "completion_tokens" in block:
        zwischengespeichert = 0
        einzelheiten = block.get("prompt_tokens_details")
        if isinstance(einzelheiten, dict):
            zwischengespeichert = int(einzelheiten.get("cached_tokens") or 0)
        return (
            max(0, int(block.get("prompt_tokens") or 0) - zwischengespeichert),
            int(block.get("completion_tokens") or 0),
            0,
            zwischengespeichert,
        )

    # Google / Gemini
    if "promptTokenCount" in block or "candidatesTokenCount" in block:
        return (
            int(block.get("promptTokenCount") or 0),
            int(block.get("candidatesTokenCount") or 0)
            + int(block.get("thoughtsTokenCount") or 0),
            0,
            int(block.get("cachedContentTokenCount") or 0),
        )
    return None


def bloecke_finden(eintrag, tiefe=TIEFE):
    """Alle Verbrauchsbloecke in einem Eintrag, egal wie tief sie stecken.

    ⚠ `total_token_usage` wird dabei uebersprungen: das ist bei Codex die laufende Summe der
    ganzen Sitzung. Wer sie mitzaehlt, zaehlt jede Sitzung so oft, wie sie Zuege hatte.
    Danebensteht `last_token_usage` mit dem Zuwachs - und genau der ist die Mahlzeit.
    """
    gefunden = []

    def gehen(knoten, ebene):
        if ebene > tiefe or not isinstance(knoten, dict):
            return
        einzeln = verbrauch_lesen(knoten)
        if einzeln is not None:
            gefunden.append(einzeln)
            return                      # tiefer suchen waere doppelt gezaehlt
        for schluessel, wert in knoten.items():
            if schluessel in ("total_token_usage", "totalTokenUsage", "cumulative"):
                continue
            if isinstance(wert, dict):
                gehen(wert, ebene + 1)
            elif isinstance(wert, list) and ebene < tiefe:
                for teil in wert[:64]:
                    gehen(teil, ebene + 1)

    gehen(eintrag, 0)
    return gefunden


def zeit_finden(eintrag, tiefe=3):
    """Den Zeitstempel eines Eintrags suchen, egal wie das Feld heisst."""
    namen = ("timestamp", "time", "ts", "created_at", "createdAt", "date", "datetime")
    if isinstance(eintrag, dict):
        for name in namen:
            wann = _zeitstempel(eintrag.get(name))
            if wann is not None:
                return wann
        if tiefe > 0:
            for wert in eintrag.values():
                if isinstance(wert, dict):
                    wann = zeit_finden(wert, tiefe - 1)
                    if wann is not None:
                        return wann
    return None


def modell_finden(eintrag, tiefe=3):
    if isinstance(eintrag, dict):
        for name in ("model", "modell", "model_name", "modelVersion"):
            wert = eintrag.get(name)
            if isinstance(wert, str) and wert:
                return wert[:48]
        if tiefe > 0:
            for wert in eintrag.values():
                if isinstance(wert, dict):
                    gefunden = modell_finden(wert, tiefe - 1)
                    if gefunden:
                        return gefunden
    return None


# Ordner, unter denen ein Projekt die **erste** Ebene ist. Alles darunter zaehlt zum selben
# Projekt, damit ein Unterordner nicht als eigenes Projekt auftaucht.
#
# ⚠ Warum als Umgebungsvariable und nicht schlicht `~/projekte`: im Container laeuft der
# Dienst als root, dort ist `~` gleich `/root` und die Regel greift nie. Gemessen am
# 11.09.2026 - der Container schrieb `_bilder` und `dev` als eigene Projekte, wo die
# Sitzungen in Wahrheit alle zu `silke-buschner` gehoerten.
SAMMELMAPPEN = [
    ordner for ordner in
    (os.environ.get("FUTTER_PROJEKTE") or os.path.expanduser("~/projekte")).split(":")
    if ordner
] + ["/opt/claude-workspace"]


def _projektname(cwd):
    """Woran gerade gearbeitet wird, aus dem Arbeitsverzeichnis der Sitzung.

    Der Ordnername im Transcript-Pfad taugt dafuer nicht: dort ist der ganze Pfad mit
    Bindestrichen verklebt, und '/home/sbstn/projekte' wuerde als Projekt 'projekte' gelesen,
    obwohl es die Sammelmappe ist. Der Eintrag selbst fuehrt sein `cwd` mit, das ist die
    verlaessliche Angabe.
    """
    if not cwd or cwd == "/":
        return "system"
    cwd = str(cwd).rstrip("/")
    for sammelmappe in SAMMELMAPPEN:
        if cwd.startswith(sammelmappe + "/"):
            return cwd[len(sammelmappe) + 1:].split("/")[0]
        if cwd == sammelmappe:
            return os.path.basename(sammelmappe) + " (Sammelmappe)"
    return os.path.basename(cwd) or "system"


def _cwd_finden(eintrag, tiefe=2):
    if isinstance(eintrag, dict):
        for name in ("cwd", "workdir", "working_directory", "projectRoot"):
            wert = eintrag.get(name)
            if isinstance(wert, str) and wert:
                return wert
        if tiefe > 0:
            for wert in eintrag.values():
                if isinstance(wert, dict):
                    gefunden = _cwd_finden(wert, tiefe - 1)
                    if gefunden:
                        return gefunden
    return None


# -- Der Zaehler -------------------------------------------------------------------------

class Futterzaehler:
    """Liest die Sitzungsdateien inkrementell und haelt die Summen im Speicher.

    Inkrementell heisst: je Datei wird gemerkt, wie weit schon gelesen wurde - bei JSONL das
    Byte, bei einer ganzen JSON-Datei die Zahl der bereits gezaehlten Verbrauchsbloecke. Beim
    naechsten Durchgang beginnt der Lauf dort. Nur so bleibt der Dienst schnell, wenn die
    Dateien ueber Monate auf hunderte Megabyte anwachsen. Wird eine Datei kuerzer als ihr
    Merkposten (geloescht und neu angelegt), wird sie von vorn gelesen.
    """

    def __init__(self, naepfe):
        self.naepfe = naepfe                   # name -> absoluter Pfad
        self.stellen = {}                      # pfad -> Merkposten
        self.eimer = defaultdict(lambda: defaultdict(int))          # zeit -> art -> tokens
        self.projekt_eimer = defaultdict(lambda: defaultdict(int))  # zeit -> projekt -> tokens
        self.modell_eimer = defaultdict(lambda: defaultdict(int))   # zeit -> modell -> tokens
        self.napf_eimer = defaultdict(lambda: defaultdict(int))     # zeit -> napf -> tokens
        self.letzte_zeit = None                # Zeitstempel der juengsten gesehenen Antwort
        self.gesehen = 0                       # Anzahl verarbeiteter Verbrauchsbloecke
        self.je_napf_gesehen = defaultdict(int)
        self.sperre = threading.Lock()

    # -- Dateien -------------------------------------------------------------------

    def _dateien(self, napf, wurzel):
        for ordner, _, namen in os.walk(wurzel):
            for name in namen:
                if name.endswith((".jsonl", ".json")):
                    yield napf, os.path.join(ordner, name)

    def einlesen(self):
        for napf, wurzel in self.naepfe.items():
            if not os.path.isdir(wurzel):
                continue
            for _, pfad in self._dateien(napf, wurzel):
                try:
                    if pfad.endswith(".jsonl"):
                        self._jsonl_lesen(napf, pfad)
                    else:
                        self._json_lesen(napf, pfad)
                except OSError:
                    continue

    def _jsonl_lesen(self, napf, pfad):
        groesse = os.path.getsize(pfad)
        begonnen = self.stellen.get(pfad, 0)
        if groesse < begonnen:              # Datei wurde ersetzt
            begonnen = 0
        if groesse == begonnen:
            return
        with open(pfad, "r", encoding="utf-8", errors="replace") as datei:
            datei.seek(begonnen)
            for zeile in datei:
                if not zeile.endswith("\n"):
                    # Halb geschriebene letzte Zeile: nicht verarbeiten, beim naechsten
                    # Durchgang steht sie vollstaendig da.
                    break
                self._zeile(napf, zeile, pfad)
                begonnen += len(zeile.encode("utf-8"))
        self.stellen[pfad] = begonnen

    def _json_lesen(self, napf, pfad):
        """Eine ganze JSON-Datei. Gemerkt wird die Zahl der schon gezaehlten Blocke.

        Gemini schreibt seine Sitzungen nicht zeilenweise, sondern als eine Datei, die bei
        jedem Zug neu geschrieben wird. Ein Byte-Merkposten hilft da nicht; die Zahl der
        bereits gesehenen Verbrauchsblocke schon, denn angehaengt wird immer hinten.
        """
        # Die Marke ist Schreibzeit **und** Groesse, und sie gilt nur fuer Dateien, an denen
        # gerade nicht geschrieben wird. Sonst gibt es ein Zeitloch: wird eine Datei in
        # derselben Schreibzeit veraendert, in der sie gelesen wurde, bliebe die Aenderung
        # fuer immer unsichtbar. Genau das ist beim Bau hier aufgetreten, unregelmaessig und
        # nur unter Last - deshalb die zwei Sekunden Misstrauen.
        marke = (os.path.getmtime(pfad), os.path.getsize(pfad))
        frisch_geschrieben = time.time() - marke[0] < 2.0
        if self.stellen.get((pfad, "zeit")) == marke and not frisch_geschrieben:
            return                          # unveraendert seit dem letzten Durchgang
        with open(pfad, "r", encoding="utf-8", errors="replace") as datei:
            roh = datei.read(32 * 1024 * 1024)
        try:
            inhalt = json.loads(roh)
        except ValueError:
            # Halb geschriebene Datei: Marke nicht setzen, damit der naechste Durchgang es
            # noch einmal versucht.
            return
        eintraege = inhalt if isinstance(inhalt, list) else [inhalt]
        alle = []
        for eintrag in eintraege:
            alle.extend(self._eintrag(napf, eintrag))

        gesehen_vorher = self.stellen.get(pfad, 0)
        if len(alle) < gesehen_vorher:
            # Kuerzer als beim letzten Mal: die Datei wurde ersetzt, nicht fortgeschrieben.
            # Dann zaehlt sie von vorn - sonst fiele der neue Inhalt stillschweigend aus.
            gesehen_vorher = 0
        for teil in alle[gesehen_vorher:]:
            self._verbuchen(napf, *teil)
        self.stellen[pfad] = len(alle)
        self.stellen[(pfad, "zeit")] = marke

    # -- Einzelne Eintraege --------------------------------------------------------

    def _zeile(self, napf, zeile, pfad=None):
        # Der billige Vorfilter spart das Auspacken der allermeisten Zeilen - und sorgt
        # nebenbei dafuer, dass Nutzertexte und Werkzeugausgaben gar nicht erst geparst werden.
        if '"usage"' not in zeile and "TokenCount" not in zeile and "_tokens" not in zeile:
            return
        try:
            eintrag = json.loads(zeile)
        except ValueError:
            return
        for teil in self._eintrag(napf, eintrag):
            self._verbuchen(napf, *teil)

    def _eintrag(self, napf, eintrag):
        """Gibt je gefundenem Verbrauchsblock (zahlen, wann, projekt, modell) zurueck."""
        if not isinstance(eintrag, dict):
            return []
        bloecke = bloecke_finden(eintrag)
        if not bloecke:
            return []
        wann = zeit_finden(eintrag)
        if wann is None:
            return []
        projekt = _projektname(_cwd_finden(eintrag))
        modell = modell_finden(eintrag)
        return [(zahlen, wann, projekt, modell) for zahlen in bloecke]

    def _verbuchen(self, napf, zahlen, wann, projekt, modell):
        ein, aus, neu, gelesen = zahlen
        if not (ein or aus or neu or gelesen):
            return
        stempel = int(wann.timestamp())
        eimerzeit = stempel - (stempel % EIMER_S)

        eimer = self.eimer[eimerzeit]
        eimer["ein"] += ein
        eimer["aus"] += aus
        eimer["cache_neu"] += neu
        eimer["cache_gelesen"] += gelesen

        # Was als Nahrung zaehlt: frisch verarbeiteter Text plus erzeugte Antwort. Gelesener
        # Cache ist Wiederkaeuen und macht nicht satt, deshalb steht er getrennt daneben.
        nahrung = ein + aus + neu
        self.projekt_eimer[eimerzeit][projekt] += nahrung
        self.napf_eimer[eimerzeit][napf] += nahrung
        if modell:
            self.modell_eimer[eimerzeit][modell] += nahrung
        self.gesehen += 1
        self.je_napf_gesehen[napf] += 1
        if self.letzte_zeit is None or wann > self.letzte_zeit:
            self.letzte_zeit = wann

    # -- Auswerten -----------------------------------------------------------------

    def aufraeumen(self):
        grenze = int((_jetzt() - timedelta(days=GEDAECHTNIS_TAGE)).timestamp())
        for reihe in (self.eimer, self.projekt_eimer, self.modell_eimer, self.napf_eimer):
            for zeit in [z for z in reihe if z < grenze]:
                del reihe[zeit]

    def _aufteilung(self, reihe, ab, anzahl=8):
        """Summiert eine der Nebenreihen (Projekte, Modelle, Naepfe) ueber ein Zeitfenster."""
        grenze = int(ab.timestamp())
        summe = defaultdict(int)
        for zeit, werte in reihe.items():
            if zeit >= grenze:
                for name, tokens in werte.items():
                    summe[name] += tokens
        geordnet = sorted(summe.items(), key=lambda p: -p[1])[:anzahl]
        return [{"name": n, "nahrung": t} for n, t in geordnet]

    def _summe(self, ab):
        grenze = int(ab.timestamp())
        ein = aus = neu = gelesen = 0
        for zeit, werte in self.eimer.items():
            if zeit >= grenze:
                ein += werte["ein"]
                aus += werte["aus"]
                neu += werte["cache_neu"]
                gelesen += werte["cache_gelesen"]
        return {
            "ein": ein, "aus": aus, "cache_neu": neu, "cache_gelesen": gelesen,
            "nahrung": ein + aus + neu,
        }

    def naepfe_stand(self, ab):
        """Je Napf: was er heute beigetragen hat und ob sein Ordner ueberhaupt da ist."""
        summen = {eintrag["name"]: eintrag["nahrung"] for eintrag in
                  self._aufteilung(self.napf_eimer, ab, anzahl=16)}
        heraus = []
        for name, wurzel in self.naepfe.items():
            da = os.path.isdir(wurzel)
            heraus.append({
                "name": name,
                "titel": NAEPFE.get(name, {}).get("titel", name.upper()),
                "pfad": wurzel,
                "vorhanden": da,
                "bloecke": self.je_napf_gesehen.get(name, 0),
                "nahrung": summen.get(name, 0),
            })
        return heraus

    def stand(self):
        with self.sperre:
            self.einlesen()
            self.aufraeumen()
            jetzt = _jetzt()

            verlauf = []
            aktueller = int(jetzt.timestamp()) - (int(jetzt.timestamp()) % EIMER_S)
            for i in range(EIMER_ANZAHL - 1, -1, -1):
                zeit = aktueller - i * EIMER_S
                werte = self.eimer.get(zeit)
                verlauf.append(
                    (werte["ein"] + werte["aus"] + werte["cache_neu"]) if werte else 0
                )

            still_s = None
            if self.letzte_zeit is not None:
                still_s = max(0, int((jetzt - self.letzte_zeit).total_seconds()))

            tagesbeginn = jetzt.replace(hour=0, minute=0, second=0, microsecond=0)

            return {
                "stand": jetzt.isoformat(timespec="seconds"),
                "fuenf_minuten": self._summe(jetzt - timedelta(minutes=5)),
                "stunde": self._summe(jetzt - timedelta(hours=1)),
                "heute": self._summe(tagesbeginn),
                "sieben_tage": self._summe(jetzt - timedelta(days=7)),
                "verlauf": {"eimer_s": EIMER_S, "werte": verlauf},
                "still_s": still_s,
                "letzte_mahlzeit": self.letzte_zeit.isoformat(timespec="seconds")
                if self.letzte_zeit else None,
                "projekte": self._aufteilung(self.projekt_eimer, tagesbeginn),
                "modelle": self._aufteilung(self.modell_eimer, tagesbeginn),
                "naepfe": self.naepfe_stand(tagesbeginn),
                "bloecke": self.gesehen,
            }

# ======================================================================================
# Einstellungen, Versand und Autostart. Das ist der Teil, den es im Futterdienst nicht gibt.
# ======================================================================================

TAKT_S = 60
ZEITLIMIT_S = 20


def einstellungsdatei():
    """Wo die Einstellungen liegen. Je System dort, wo Einstellungen hingehoeren."""
    if sys.platform == "win32":
        wurzel = os.environ.get("APPDATA") or os.path.expanduser("~")
        return os.path.join(wurzel, "Tokagotchi", "sammler.json")
    if sys.platform == "darwin":
        return os.path.expanduser("~/Library/Application Support/Tokagotchi/sammler.json")
    wurzel = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
    return os.path.join(wurzel, "tokagotchi", "sammler.json")


def einstellungen_lesen():
    pfad = einstellungsdatei()
    if not os.path.exists(pfad):
        return {}
    try:
        with open(pfad, "r", encoding="utf-8") as datei:
            werte = json.load(datei)
        return werte if isinstance(werte, dict) else {}
    except (OSError, ValueError) as fehler:
        print(f"{pfad} unlesbar ({fehler})", file=sys.stderr)
        return {}


def einstellungen_schreiben(werte):
    """Ueber eine Nebendatei und `os.replace`, damit nie eine halbe Datei dasteht."""
    pfad = einstellungsdatei()
    os.makedirs(os.path.dirname(pfad), exist_ok=True)
    neben = pfad + ".neu"
    with open(neben, "w", encoding="utf-8") as datei:
        json.dump(werte, datei, ensure_ascii=False, indent=2, sort_keys=True)
    try:
        os.chmod(neben, 0o600)              # unter Windows wirkungslos, stoert aber nicht
    except OSError:
        pass
    os.replace(neben, pfad)
    return pfad


def naepfe_waehlen(einstellungen):
    """Welche Ordner gelesen werden. Fehlende kosten nichts, sie werden uebersprungen."""
    abgeschaltet = set(einstellungen.get("aus") or [])
    gewaehlt = {}
    for name, vorgabe in NAEPFE.items():
        if name in abgeschaltet:
            continue
        pfad = (einstellungen.get("pfade") or {}).get(name) or vorgabe["pfad"]
        gewaehlt[name] = os.path.expanduser(pfad)
    return gewaehlt


def schicken(wurzel, schluessel, stand, ohne_projekte=False):
    """Den Stand hochladen. Gibt einen Fehlertext zurueck oder None."""
    nutzlast = dict(stand)
    if ohne_projekte:
        nutzlast["projekte"] = []
    koerper = json.dumps(nutzlast).encode("utf-8")
    anfrage = urllib.request.Request(
        wurzel.rstrip("/") + "/api/futter",
        data=koerper,
        headers={
            "Content-Type": "application/json",
            "Authorization": "Bearer " + schluessel,
            "User-Agent": "tokagotchi-sammler/1.0",
        },
        method="POST",
    )
    try:
        with urllib.request.urlopen(anfrage, timeout=ZEITLIMIT_S) as antwort:
            antwort.read()
        return None
    except urllib.error.HTTPError as fehler:
        # Klartext statt Fehlernummer: wer das im Protokoll liest, will wissen, was zu tun ist.
        if fehler.code in (401, 403):
            return "Schluessel abgelehnt - stimmt er noch?"
        if fehler.code == 404:
            return f"{wurzel} kennt /api/futter nicht - stimmt die Adresse?"
        return f"HTTP {fehler.code}"
    except (urllib.error.URLError, OSError, TimeoutError) as fehler:
        return f"nicht erreichbar ({fehler})"


# -- Autostart je System ---------------------------------------------------------------

SYSTEMD = """[Unit]
Description=Tokagotchi-Sammler
After=network-online.target

[Service]
Type=simple
ExecStart={python} {skript}
Restart=always
RestartSec=30

[Install]
WantedBy=default.target
"""

LAUNCHD = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>de.tokagotchi.sammler</string>
  <key>ProgramArguments</key>
  <array><string>{python}</string><string>{skript}</string></array>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><true/>
  <key>StandardErrorPath</key><string>{protokoll}</string>
</dict>
</plist>
"""


def autostart_einrichten(skript):
    """Den Sammler beim Anmelden mitstarten lassen. Je System anders, aber immer ohne
    Administratorrechte - er laeuft als der Benutzer, dessen Dateien er liest."""
    python = sys.executable or "python3"

    if sys.platform == "linux":
        ordner = os.path.expanduser("~/.config/systemd/user")
        os.makedirs(ordner, exist_ok=True)
        pfad = os.path.join(ordner, "tokagotchi-sammler.service")
        with open(pfad, "w", encoding="utf-8") as datei:
            datei.write(SYSTEMD.format(python=python, skript=skript))
        for befehl in (["systemctl", "--user", "daemon-reload"],
                       ["systemctl", "--user", "enable", "--now", "tokagotchi-sammler"]):
            subprocess.run(befehl, check=False)
        # Ohne das laeuft der Nutzerdienst nur, solange jemand angemeldet ist.
        subprocess.run(["loginctl", "enable-linger", os.environ.get("USER", "")], check=False)
        return f"systemd-Nutzerdienst: {pfad}\n  ansehen: systemctl --user status tokagotchi-sammler"

    if sys.platform == "darwin":
        ordner = os.path.expanduser("~/Library/LaunchAgents")
        os.makedirs(ordner, exist_ok=True)
        pfad = os.path.join(ordner, "de.tokagotchi.sammler.plist")
        protokoll = os.path.expanduser("~/Library/Logs/tokagotchi-sammler.log")
        with open(pfad, "w", encoding="utf-8") as datei:
            datei.write(LAUNCHD.format(python=python, skript=skript, protokoll=protokoll))
        subprocess.run(["launchctl", "unload", pfad], check=False,
                       capture_output=True)
        subprocess.run(["launchctl", "load", pfad], check=False)
        return f"launchd-Agent: {pfad}\n  Protokoll: {protokoll}"

    if sys.platform == "win32":
        # pythonw statt python, sonst steht bei jedem Anmelden ein schwarzes Fenster offen.
        stiller = python.replace("python.exe", "pythonw.exe")
        if not os.path.exists(stiller):
            stiller = python
        befehl = ["schtasks", "/create", "/tn", "Tokagotchi-Sammler", "/sc", "onlogon",
                  "/tr", f'"{stiller}" "{skript}"', "/f"]
        lauf = subprocess.run(befehl, check=False, capture_output=True, text=True)
        if lauf.returncode != 0:
            return ("Aufgabe konnte nicht angelegt werden:\n  "
                    + (lauf.stderr or lauf.stdout).strip()
                    + "\n  Von Hand: " + " ".join(befehl))
        return "Aufgabenplanung: Tokagotchi-Sammler (beim Anmelden)"

    return f"Autostart fuer {sys.platform} nicht vorgesehen - von Hand starten."


# -- Hauptprogramm ----------------------------------------------------------------------

def _bericht(stand):
    summe = stand["heute"]
    zeilen = [f"heute {summe['nahrung']:,} Tokens in {stand['bloecke']} Bloecken".replace(",", "."),
              f"letzte Stunde {stand['stunde']['nahrung']:,}".replace(",", ".")]
    for napf in stand["naepfe"]:
        zustand = "fehlt" if not napf["vorhanden"] else f"{napf['nahrung']:,}".replace(",", ".")
        zeilen.append(f"  {napf['titel']:<8} {zustand}")
    return "\n".join(zeilen)


def main():
    zerteiler = argparse.ArgumentParser(
        description="Liest den lokalen Tokenverbrauch und schickt ihn an deinen Tokagotchi.")
    zerteiler.add_argument("--einrichten", nargs=2, metavar=("ADRESSE", "SCHLUESSEL"),
                           help="Einstellungen anlegen und Autostart einrichten")
    zerteiler.add_argument("--einmal", action="store_true", help="einmal schicken, dann Schluss")
    zerteiler.add_argument("--probe", action="store_true",
                           help="nur anzeigen, was gefunden wurde - nichts verschicken")
    zerteiler.add_argument("--ohne-projekte", action="store_true",
                           help="Projektnamen nicht mitschicken")
    zerteiler.add_argument("--takt", type=float, default=None,
                           help=f"Sekunden zwischen zwei Sendungen (Vorgabe {TAKT_S})")
    argumente = zerteiler.parse_args()

    einstellungen = einstellungen_lesen()

    if argumente.einrichten:
        adresse, schluessel = argumente.einrichten
        einstellungen.update({"adresse": adresse.rstrip("/"), "schluessel": schluessel})
        pfad = einstellungen_schreiben(einstellungen)
        print(f"Einstellungen: {pfad}")
        skript = os.path.abspath(__file__)
        print(autostart_einrichten(skript))
        # Gleich einmal schicken, damit ein Fehler sofort auffaellt und nicht erst morgen.
        zaehler = Futterzaehler(naepfe_waehlen(einstellungen))
        fehler = schicken(adresse, schluessel, zaehler.stand(), argumente.ohne_projekte)
        print("Probesendung:", fehler or "angekommen")
        return 1 if fehler else 0

    naepfe = naepfe_waehlen(einstellungen)
    zaehler = Futterzaehler(naepfe)

    if argumente.probe:
        print("Naepfe:")
        for name, pfad in naepfe.items():
            print(f"  {name:<8} {pfad}  {'da' if os.path.isdir(pfad) else 'FEHLT'}")
        print()
        print(_bericht(zaehler.stand()))
        return 0

    adresse = einstellungen.get("adresse")
    schluessel = einstellungen.get("schluessel")
    if not adresse or not schluessel:
        print("Noch nicht eingerichtet. Einmal aufrufen mit:\n"
              f"  {sys.executable} {os.path.abspath(__file__)} "
              "--einrichten https://tokagotchi.de DEIN-SCHLUESSEL", file=sys.stderr)
        return 1

    takt = argumente.takt or einstellungen.get("takt_s") or TAKT_S
    print(f"Sammler laeuft. Ziel {adresse}, alle {takt:.0f} s, "
          f"{len(naepfe)} Naepfe, Python {platform.python_version()} auf {sys.platform}.")

    letzter_fehler = None
    while True:
        try:
            fehler = schicken(adresse, schluessel, zaehler.stand(), argumente.ohne_projekte)
        except Exception as ausnahme:            # noqa: BLE001 - der Sammler darf nie sterben
            fehler = f"unerwartet: {ausnahme!r}"
        # Nur bei Wechsel melden, sonst laeuft das Protokoll bei totem Netz ueber.
        if fehler != letzter_fehler:
            print(time.strftime("%Y-%m-%d %H:%M:%S"),
                  fehler or "angekommen", flush=True)
            letzter_fehler = fehler
        if argumente.einmal:
            return 1 if fehler else 0
        time.sleep(takt)


if __name__ == "__main__":
    sys.exit(main())
