/* ================================================================
   WebSocket-Verbindung: Push statt Abfragen
   ================================================================
   Ohne das eingespritzte hass-Objekt gibt es keine automatischen Updates
   mehr. Statt im Sekundentakt zu pollen (teuer auf dem Mobilfunknetz und
   für den Akku) hängt sich die App an HAs eigenen Ereignisstrom.

   Ablauf laut HA-WebSocket-API:
     Server: {type:"auth_required"}
     Client: {type:"auth", access_token:"..."}
     Server: {type:"auth_ok"}   oder  {type:"auth_invalid"}
     Client: {id:1, type:"subscribe_events", event_type:"state_changed"}

   Der Verbindungszustand ist zugleich die Quelle für den Offline-Hinweis in
   der Kopfzeile (COMPANION_APP_ARCHITECTURE.md §3 "Offline behavior"). */

import type { HassState } from "./types.ts";
import { websocketUrl, zugangLesen, type Zugang } from "./umgebung.ts";

export type Verbindungszustand = "getrennt" | "verbindet" | "verbunden" | "nicht_angemeldet";

export interface ZustandsWechsel {
  entity_id: string;
  new_state: HassState | null;
  old_state: HassState | null;
}

type ZustandsHorcher = (wechsel: ZustandsWechsel) => void;
type VerbindungsHorcher = (zustand: Verbindungszustand) => void;

/* Wartezeiten beim Wiederverbinden: schnell beim ersten Versuch (kurze
   Funklöcher), dann zunehmend geduldiger, gedeckelt bei 30 s. Ohne Deckel
   würde die App nach einer langen Nacht ohne Netz stundenlang schlafen. */
const WARTEZEITEN_MS = [1_000, 2_000, 5_000, 10_000, 30_000] as const;

export class HassLive {
  #socket: WebSocket | null = null;
  #zustand: Verbindungszustand = "getrennt";
  #naechsteId = 1;
  #versuch = 0;
  #wiederholung: ReturnType<typeof setTimeout> | null = null;
  #beendet = false;

  #zustandsHorcher = new Set<ZustandsHorcher>();
  #verbindungsHorcher = new Set<VerbindungsHorcher>();

  get zustand(): Verbindungszustand {
    return this.#zustand;
  }

  get istVerbunden(): boolean {
    return this.#zustand === "verbunden";
  }

  /** Meldet jede Zustandsänderung einer Entität. Rückgabe: Abmeldefunktion. */
  aufZustand(horcher: ZustandsHorcher): () => void {
    this.#zustandsHorcher.add(horcher);
    return () => this.#zustandsHorcher.delete(horcher);
  }

  /** Meldet Verbindungswechsel - treibt den Offline-Hinweis in der Kopfzeile. */
  aufVerbindung(horcher: VerbindungsHorcher): () => void {
    this.#verbindungsHorcher.add(horcher);
    horcher(this.#zustand);
    return () => this.#verbindungsHorcher.delete(horcher);
  }

  #zustandSetzen(neu: Verbindungszustand): void {
    if (this.#zustand === neu) return;
    this.#zustand = neu;
    for (const h of this.#verbindungsHorcher) h(neu);
  }

  async starten(zugang?: Zugang): Promise<void> {
    this.#beendet = false;
    const z = zugang ?? (await zugangLesen());
    if (!z) {
      this.#zustandSetzen("nicht_angemeldet");
      return;
    }
    this.#verbinden(z);
  }

  beenden(): void {
    this.#beendet = true;
    if (this.#wiederholung) {
      clearTimeout(this.#wiederholung);
      this.#wiederholung = null;
    }
    this.#socket?.close();
    this.#socket = null;
    this.#zustandSetzen("getrennt");
  }

  #verbinden(zugang: Zugang): void {
    this.#zustandSetzen("verbindet");

    let socket: WebSocket;
    try {
      socket = new WebSocket(websocketUrl(zugang.basisUrl));
    } catch {
      this.#erneutVersuchen(zugang);
      return;
    }
    this.#socket = socket;

    socket.addEventListener("message", (ereignis) => {
      let nachricht: Record<string, unknown>;
      try {
        nachricht = JSON.parse(String(ereignis.data));
      } catch {
        return;
      }

      switch (nachricht["type"]) {
        case "auth_required":
          socket.send(JSON.stringify({ type: "auth", access_token: zugang.token }));
          break;

        case "auth_ok":
          this.#versuch = 0;
          this.#zustandSetzen("verbunden");
          socket.send(
            JSON.stringify({ id: this.#naechsteId++, type: "subscribe_events", event_type: "state_changed" }),
          );
          break;

        case "auth_invalid":
          /* Token ungültig - erneutes Verbinden hilft nicht, es braucht ein
             neues Token. Deshalb hier kein Wiederholungsversuch. */
          this.#beendet = true;
          this.#zustandSetzen("nicht_angemeldet");
          socket.close();
          break;

        case "event": {
          const ereignisDaten = nachricht["event"] as { event_type?: string; data?: ZustandsWechsel } | undefined;
          if (ereignisDaten?.event_type === "state_changed" && ereignisDaten.data) {
            for (const h of this.#zustandsHorcher) h(ereignisDaten.data);
          }
          break;
        }

        default:
          break;
      }
    });

    socket.addEventListener("close", () => {
      if (this.#socket === socket) this.#socket = null;
      if (this.#zustand !== "nicht_angemeldet") this.#erneutVersuchen(zugang);
    });

    socket.addEventListener("error", () => {
      /* "error" kommt immer zusammen mit "close" - dort wird wiederholt. */
    });
  }

  #erneutVersuchen(zugang: Zugang): void {
    if (this.#beendet) return;
    this.#zustandSetzen("getrennt");

    const wartezeit = WARTEZEITEN_MS[Math.min(this.#versuch, WARTEZEITEN_MS.length - 1)] ?? 30_000;
    this.#versuch += 1;
    this.#wiederholung = setTimeout(() => this.#verbinden(zugang), wartezeit);
  }
}
