import { signRequest } from "../security/hmac.ts";
import type { BotConfig } from "../config.ts";
import type { ChannelStatus, EngineEvent, InboundKickEvent, OutboundMessage } from "../types.ts";

export class PanelClient {
  readonly config: BotConfig;

  constructor(config: BotConfig) {
    this.config = config;
  }

  async channels(signal?: AbortSignal): Promise<ChannelStatus[]> {
    const payload = await this.request<{ channels: ChannelStatus[] }>("GET", "/api/worker/channels", undefined, signal);
    return payload.channels;
  }

  async channelConfig(channelId: number, signal?: AbortSignal): Promise<Record<string, unknown>> {
    return this.request("GET", `/api/worker/config?channel_id=${channelId}`, undefined, signal);
  }

  /**
   * A game engine may be deployed twice temporarily while a service is
   * restarting.  The server-side lease makes one process the sole owner of a
   * running game session, so a second process cannot replay its first round.
   */
  async gameLease(
    channelId: number,
    gameSessionId: number,
    instanceId: string,
    action: "claim" | "renew" | "release" = "claim"
  ): Promise<boolean> {
    const payload = await this.request<{ leased?: boolean }>(
      "POST",
      `/api/worker/games/${gameSessionId}/lease`,
      { channel_id: channelId, instance_id: instanceId, action }
    );
    return payload.leased === true;
  }

  async queue(signal?: AbortSignal): Promise<OutboundMessage[]> {
    // The queue is on the command path. Keep a firm local-VPS bound so a
    // stuck panel request cannot freeze the dedicated outbound poller.
    const payload = await this.request<{ messages: OutboundMessage[] }>("GET", "/api/worker/queue", undefined, this.withTimeout(signal, 1_500));
    return payload.messages;
  }

  async acknowledge(message: OutboundMessage, status: "sent" | "retry" | "failed", error = ""): Promise<void> {
    await this.request("POST", `/api/worker/messages/${message.id}/ack`, {
      status,
      error: error.slice(0, 500),
      attempts: message.attempts
    });
  }

  async events(events: EngineEvent[]): Promise<void> {
    if (!events.length) return;
    // Processing an incoming command can legitimately touch points, games and
    // overlays.  A 1.2 s limit caused completed work to be abandoned by the
    // engine during a busy OBS scene, leaving the webhook claimed for minutes.
    await this.request("POST", "/api/worker/events", { events: events.slice(0, 100) }, this.withTimeout(undefined, 3_500));
  }

  async inboundEvents(signal?: AbortSignal): Promise<InboundKickEvent[]> {
    const payload = await this.request<{ events: InboundKickEvent[] }>(
      "GET",
      "/api/worker/inbound-events",
      undefined,
      // Brief PHP/MariaDB contention must not make the worker abandon a
      // claimed webhook after only two seconds.  3.5 s is still bounded,
      // while preventing needless retry cycles that were visible as a slow
      // response in Kick chat.
      this.withTimeout(signal, 3_500)
    );
    return payload.events;
  }

  async acknowledgeInboundEvent(
    id: number,
    status: "processed" | "retry" | "failed",
    error = ""
  ): Promise<void> {
    await this.request("POST", `/api/worker/inbound-events/${id}/ack`, {
      status,
      error: error.slice(0, 500)
    });
  }

  async heartbeat(data: Record<string, unknown>): Promise<void> {
    await this.request("POST", "/api/worker/heartbeat", {
      version: "0.1.0",
      ...data
    });
  }

  async sessionStatus(status: string, extra: Record<string, unknown> = {}): Promise<void> {
    await this.request("POST", "/api/worker/session-status", { status, ...extra });
  }

  async moderatorStatus(channelId: number, status: string, botKickUserId: string, error = ""): Promise<void> {
    await this.request("POST", "/api/worker/moderator-status", {
      channel_id: channelId,
      status,
      bot_kick_user_id: botKickUserId,
      error: error.slice(0, 500)
    });
  }

  async syncStreamlabsDonations(): Promise<number> {
    const payload = await this.request<{ processed?: number }>("POST", "/api/worker/streamlabs/sync", {}, this.withTimeout(undefined, 12_000));
    return Number(payload.processed ?? 0);
  }

  async workerCommands(signal?: AbortSignal): Promise<Array<{ id: number; command_type: string; payload_json: string }>> {
    const payload = await this.request<{ commands: Array<{ id: number; command_type: string; payload_json: string }> }>(
      "GET",
      "/api/worker/commands",
      undefined,
      // Worker-management actions (restart, OAuth checks…) are never on the
      // chat path.  A busy panel must not leave that background poll waiting
      // for the generic five-second HTTP limit.
      this.withTimeout(signal, 1_500)
    );
    return payload.commands;
  }

  async completeWorkerCommand(id: number, status: "completed" | "failed", result: string): Promise<void> {
    await this.request("POST", `/api/worker/commands/${id}/complete`, {
      status,
      result: result.slice(0, 1_000)
    });
  }

  private async request<T extends Record<string, unknown>>(
    method: string,
    pathAndQuery: string,
    data?: Record<string, unknown>,
    signal?: AbortSignal
  ): Promise<T> {
    const body = data ? JSON.stringify(data) : "";
    const url = new URL(pathAndQuery, this.config.panelUrl);
    const signedPath = url.pathname;
    const headers = {
      Accept: "application/json",
      ...(body ? { "Content-Type": "application/json" } : {}),
      ...signRequest(method, signedPath, body, this.config.workerId, this.config.workerSecret)
    };
    // The panel runs beside the bot on the VPS. A bounded request prevents a
    // stalled maintenance call from holding the command loop indefinitely.
    const timeout = AbortSignal.timeout(5_000);
    const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
    const init: RequestInit = { method, headers, signal: requestSignal };
    if (body) init.body = body;
    let response: Response;
    try {
      response = await fetch(url, init);
    } catch (error) {
      const detail = error instanceof Error ? error.message : "requête interrompue";
      // Keep the endpoint in the service log: an otherwise identical timeout
      // from an inbound poll and an external maintenance call need different
      // remedies, while no credential or payload is ever logged.
      throw new Error(`Panel ${method} ${url.pathname}: ${detail}`);
    }
    const payload = await response.json().catch(() => ({ ok: false, error: "Réponse panel invalide" })) as T & { ok?: boolean; error?: string };
    if (!response.ok || payload.ok === false) {
      throw new Error(payload.error || `Panel HTTP ${response.status}`);
    }
    return payload;
  }

  private withTimeout(signal: AbortSignal | undefined, milliseconds: number): AbortSignal {
    const timeout = AbortSignal.timeout(milliseconds);
    return signal ? AbortSignal.any([signal, timeout]) : timeout;
  }
}
