import { createHash, createHmac, randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { LegacyGameCoordinator } from "../../bot-engine/src/games/LegacyGameCoordinator.ts";
import type { BotConfig } from "../../bot-engine/src/config.ts";
import type { ChannelStatus, ChatMessage, EngineEvent } from "../../bot-engine/src/types.ts";

type InboxEvent = {
  id: number;
  channel_id: number;
  twitch_message_id: string;
  event_type: string;
  event_version: string;
  payload_json: string;
  attempts: number;
};

type QueueMessage = {
  id: number;
  channel_id: number;
  message_text: string;
  reply_to_message_id: string | null;
  attempts: number;
  idempotency_key: string;
};

type MessagingContext = {
  client_id: string;
  access_token: string;
  broadcaster_user_id: string;
  sender_user_id: string;
};

const here = path.dirname(fileURLToPath(import.meta.url));
// src → twitch-engine → apps → FilyX. Keep this exact depth: using one level
// too many would look for /srv/.env and make only the Twitch engine fail.
const projectRoot = path.resolve(here, "..", "..", "..");

for (const line of (() => {
  try { return readFileSync(path.join(projectRoot, ".env"), "utf8").split(/\r?\n/); } catch { return []; }
})()) {
  const value = line.trim();
  if (!value || value.startsWith("#") || !value.includes("=")) continue;
  const at = value.indexOf("=");
  const key = value.slice(0, at).trim();
  let content = value.slice(at + 1).trim();
  if ((content.startsWith('"') && content.endsWith('"')) || (content.startsWith("'") && content.endsWith("'"))) content = content.slice(1, -1);
  if (process.env[key] === undefined) process.env[key] = content;
}

const required = (name: string): string => {
  const value = process.env[name]?.trim() ?? "";
  if (!value) throw new Error(`Variable requise absente : ${name}`);
  return value;
};
const panelUrl = required("FILYX_PANEL_URL").replace(/\/+$/, "");
const workerId = required("FILYX_WORKER_ID");
const workerSecret = required("FILYX_WORKER_SECRET");
const legacyRoot = process.env.FILYX_LEGACY_ROOT?.trim() || path.join(projectRoot, "legacy");

const signedRequest = async <T>(method: string, endpoint: string, body?: Record<string, unknown>): Promise<T> => {
  const payload = body ? JSON.stringify(body) : "";
  const url = new URL(endpoint, panelUrl);
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const nonce = randomUUID();
  // HmacVerifier uses a SHA-256 hash of the raw body. Keeping it separate
  // from JSON parsing makes retries with an empty body deterministic.
  const sha256 = createHash("sha256").update(payload).digest("hex");
  const canonical = [method.toUpperCase(), url.pathname, timestamp, nonce, sha256].join("\n");
  const signature = createHmac("sha256", workerSecret).update(canonical).digest("hex");
  const response = await fetch(url, {
    method,
    headers: {
      Accept: "application/json",
      ...(payload ? { "Content-Type": "application/json" } : {}),
      "X-FilyX-Worker": workerId,
      "X-FilyX-Timestamp": timestamp,
      "X-FilyX-Nonce": nonce,
      "X-FilyX-Signature": signature,
    },
    ...(payload ? { body: payload } : {}),
    signal: AbortSignal.timeout(4_000),
  });
  const parsed = await response.json().catch(() => ({ ok: false, error: "Réponse FilyX invalide" })) as T & { ok?: boolean; error?: string };
  if (!response.ok || parsed.ok === false) throw new Error(parsed.error || `FilyX HTTP ${response.status}`);
  return parsed;
};

const normalize = (row: InboxEvent): { id: string; channel_id: number; type: string; platform: "twitch"; payload: Record<string, unknown> } | null => {
  let wrapper: Record<string, unknown>;
  try { wrapper = JSON.parse(row.payload_json) as Record<string, unknown>; } catch { return null; }
  const event = (wrapper.event && typeof wrapper.event === "object" ? wrapper.event : {}) as Record<string, unknown>;
  const base = { id: `twitch:${row.twitch_message_id}`, channel_id: row.channel_id, platform: "twitch" as const };
  switch (row.event_type) {
    case "channel.chat.message": {
      const badges = Array.isArray(event.badges) ? event.badges : [];
      const roles = badges.map((badge) => String((badge as Record<string, unknown>).set_id ?? "").toLowerCase())
        .filter((role) => ["broadcaster", "moderator", "vip", "subscriber"].includes(role));
      const message = (event.message && typeof event.message === "object" ? event.message : {}) as Record<string, unknown>;
      // EventSub gives us ordered message fragments and the official emote id.
      // Preserve text as-is, but pass emotes as a small safe marker that the
      // OBS overlay turns into Twitch's public CDN image.
      const fragments = Array.isArray(message.fragments) ? message.fragments : [];
      const content = fragments.length
        ? fragments.map((item) => {
          const fragment = (item && typeof item === "object" ? item : {}) as Record<string, unknown>;
          const text = String(fragment.text ?? "");
          const emote = (fragment.emote && typeof fragment.emote === "object" ? fragment.emote : {}) as Record<string, unknown>;
          const emoteId = String(emote.id ?? "").trim();
            // Twitch emotes from subscriptions, shared channels and some native
            // sets use ids such as "emotesv2_…", not only numeric legacy ids.
            // Keep the accepted alphabet deliberately narrow because this value
            // is later used in a CDN path by the browser source.
            return String(fragment.type ?? "") === "emote" && /^[A-Za-z0-9_-]{1,160}$/.test(emoteId)
            ? `[emote:twitch:${emoteId}:${text.replace(/[\]\r\n:]/g, "").slice(0, 120) || "Emote"}]`
            : text;
        }).join("")
        : String(message.text ?? "");
      return {
        ...base,
        type: "chat.message",
        payload: {
          content: content.slice(0, 500),
          sender: {
            // Legacy game/points services use this stable identifier name;
            // its value is the official Twitch chatter_user_id.
            kick_user_id: String(event.chatter_user_id ?? ""),
            username: String(event.chatter_user_login ?? event.chatter_user_name ?? "viewer").slice(0, 100),
            roles,
          },
          message_id: String(event.message_id ?? row.twitch_message_id),
          created_at: String(event.message_timestamp ?? new Date().toISOString()),
        },
      };
    }
    case "channel.follow":
      return { ...base, type: "channel.followed", payload: { username: String(event.user_login ?? event.user_name ?? "Viewer"), user_id: String(event.user_id ?? ""), source: "twitch" } };
    case "channel.subscribe":
      return { ...base, type: "channel.subscription.new", payload: { username: String(event.user_login ?? event.user_name ?? "Viewer"), user_id: String(event.user_id ?? ""), tier: String(event.tier ?? ""), source: "twitch" } };
    case "channel.subscription.message":
      return { ...base, type: "channel.subscription.renewal", payload: { username: String(event.user_login ?? event.user_name ?? "Viewer"), user_id: String(event.user_id ?? ""), tier: String(event.tier ?? ""), source: "twitch" } };
    case "channel.subscription.gift":
      return { ...base, type: "channel.subscription.gifts", payload: { username: String(event.user_login ?? event.user_name ?? "Viewer"), user_id: String(event.user_id ?? ""), total: Number(event.total ?? 1), tier: String(event.tier ?? ""), source: "twitch" } };
    case "channel.cheer":
      return { ...base, type: "kicks.gifted", payload: { username: String(event.user_login ?? event.user_name ?? "Viewer"), user_id: String(event.user_id ?? ""), amount: Number(event.bits ?? 0), source: "twitch" } };
    case "channel.raid":
      return { ...base, type: "channel.raid.received", payload: { username: String(event.from_broadcaster_user_login ?? event.from_broadcaster_user_name ?? "Streamer"), user_id: String(event.from_broadcaster_user_id ?? ""), viewer_count: Number(event.viewers ?? 0), source: "twitch" } };
    case "stream.online":
      return { ...base, type: "livestream.status.updated", payload: { is_live: true, source: "twitch" } };
    case "stream.offline":
      return { ...base, type: "livestream.status.updated", payload: { is_live: false, source: "twitch" } };
    default:
      return { ...base, type: `twitch.${row.event_type}`.slice(0, 100), payload: { ...event, source: "twitch" } };
  }
};

type TwitchChannel = ChannelStatus & { twitch_user_id: string; twitch_login: string };
const twitchChannels = new Map<number, TwitchChannel>();
let gamesSyncBusy = false;
let gameEventBuffer: Array<EngineEvent & { platform: "twitch" }> = [];
let gameFlushTimer: NodeJS.Timeout | null = null;
let gameFlushBusy = false;

const flushGameEvents = async (): Promise<void> => {
  if (gameFlushBusy || !gameEventBuffer.length) return;
  gameFlushBusy = true;
  const events = gameEventBuffer.splice(0, 30);
  try {
    await signedRequest("POST", "/api/worker/events", { events });
  } catch {
    gameEventBuffer.unshift(...events);
    if (gameEventBuffer.length > 200) gameEventBuffer = gameEventBuffer.slice(-200);
  } finally {
    gameFlushBusy = false;
  }
};

const emitGameEvent = (event: EngineEvent): void => {
  gameEventBuffer.push({ ...event, platform: "twitch" });
  if (gameFlushTimer) return;
  gameFlushTimer = setTimeout(() => {
    gameFlushTimer = null;
    void flushGameEvents();
  }, 60);
  gameFlushTimer.unref();
};

const games = new LegacyGameCoordinator(
  { legacyRoot } as BotConfig,
  emitGameEvent,
  async (channelId, gameSessionId, action) => {
    const response = await signedRequest<{ leased?: boolean }>("POST", `/api/worker/games/${gameSessionId}/lease`, {
      channel_id: channelId,
      instance_id: `twitch-${workerId}`.replace(/[^a-z0-9-]/gi, "").slice(0, 64),
      action,
    });
    return response.leased === true;
  },
);

const syncGames = async (): Promise<void> => {
  if (gamesSyncBusy) return;
  gamesSyncBusy = true;
  try {
    const response = await signedRequest<{ channels: Array<Record<string, unknown>> }>("GET", "/api/worker/twitch/channels");
    const liveIds = new Set<number>();
    for (const source of response.channels ?? []) {
      const channelId = Number(source.channel_id ?? 0);
      if (!Number.isSafeInteger(channelId) || channelId < 1) continue;
      liveIds.add(channelId);
      const channel: TwitchChannel = {
        channel_id: channelId,
        streamer_id: Number(source.streamer_id ?? 0),
        kick_channel_id: String(source.kick_channel_id ?? ""),
        kick_user_id: String(source.kick_user_id ?? ""),
        kick_chatroom_id: String(source.kick_chatroom_id ?? ""),
        slug: String(source.slug ?? ""),
        display_name: String(source.display_name ?? source.twitch_login ?? "Twitch"),
        live_status: String(source.live_status ?? "unknown") as ChannelStatus["live_status"],
        link_status: "verified",
        bot_enabled: true,
        moderator_status: "moderator",
        twitch_user_id: String(source.twitch_user_id ?? ""),
        twitch_login: String(source.twitch_login ?? ""),
      };
      twitchChannels.set(channelId, channel);
      const config = await signedRequest<{ active_game?: unknown }>("GET", `/api/worker/config?channel_id=${channelId}`);
      await games.sync(channel, (config.active_game ?? null) as never);
    }
    for (const [channelId, channel] of twitchChannels) {
      if (!liveIds.has(channelId)) {
        await games.sync(channel, null);
        twitchChannels.delete(channelId);
      }
    }
  } finally {
    gamesSyncBusy = false;
  }
};

const gameMessage = (event: ReturnType<typeof normalize>): ChatMessage | null => {
  if (!event || event.type !== "chat.message") return null;
  const channel = twitchChannels.get(event.channel_id);
  if (!channel) return null;
  const sender = (event.payload.sender && typeof event.payload.sender === "object" ? event.payload.sender : {}) as Record<string, unknown>;
  return {
    event_id: event.id,
    channel_id: event.channel_id,
    kick_channel_id: channel.kick_channel_id,
    kick_chatroom_id: channel.kick_chatroom_id,
    content: String(event.payload.content ?? ""),
    sender: {
      kick_user_id: String(sender.kick_user_id ?? ""),
      username: String(sender.username ?? "viewer"),
      roles: Array.isArray(sender.roles) ? sender.roles.map(String) : [],
    },
    created_at: String(event.payload.created_at ?? new Date().toISOString()),
  };
};

let inboxBusy = false;
let botUserId = "";
let botUserIdRefreshAt = 0;
const refreshBotIdentity = async (): Promise<void> => {
  if (botUserIdRefreshAt > Date.now()) return;
  botUserIdRefreshAt = Date.now() + 30_000;
  try {
    const runtime = await signedRequest<{ bot_user_id?: string }>("GET", "/api/worker/twitch/runtime");
    botUserId = String(runtime.bot_user_id ?? "").trim();
  } catch {
    // The inbox keeps working while the optional identity refresh retries.
  }
};
const pollInbound = async (): Promise<void> => {
  if (inboxBusy) return;
  inboxBusy = true;
  try {
    await refreshBotIdentity();
    const result = await signedRequest<{ events: InboxEvent[] }>("GET", "/api/worker/twitch/inbound-events");
    for (const row of result.events ?? []) {
      try {
        const event = normalize(row);
        const sender = event?.type === "chat.message" && event.payload.sender && typeof event.payload.sender === "object"
          ? event.payload.sender as Record<string, unknown>
          : null;
        // Never feed the FilyX bot's own replies back through commands or
        // game answer handling. This prevents loops on Twitch only.
        if (sender && botUserId !== "" && String(sender.kick_user_id ?? "") === botUserId) {
          await signedRequest("POST", `/api/worker/twitch/inbound-events/${row.id}/ack`, { status: "processed" });
          continue;
        }
        const chat = gameMessage(event);
        if (chat) games.handleMessage(chat);
        if (event) await signedRequest("POST", "/api/worker/events", { events: [event] });
        await signedRequest("POST", `/api/worker/twitch/inbound-events/${row.id}/ack`, { status: "processed" });
      } catch (error) {
        const errorText = error instanceof Error ? error.message : "Événement Twitch indisponible";
        await signedRequest("POST", `/api/worker/twitch/inbound-events/${row.id}/ack`, { status: row.attempts >= 5 ? "failed" : "retry", error: errorText.slice(0, 500) }).catch(() => undefined);
      }
    }
  } finally {
    inboxBusy = false;
  }
};

const contextCache = new Map<number, { value: MessagingContext; until: number }>();
const contextFor = async (channelId: number): Promise<MessagingContext> => {
  const cached = contextCache.get(channelId);
  if (cached && cached.until > Date.now()) return cached.value;
  const response = await signedRequest<{ context: MessagingContext }>("POST", "/api/worker/twitch/messaging-context", { channel_id: channelId });
  contextCache.set(channelId, { value: response.context, until: Date.now() + 45_000 });
  return response.context;
};

let queueBusy = false;
const pollOutbound = async (): Promise<void> => {
  if (queueBusy) return;
  queueBusy = true;
  try {
    const result = await signedRequest<{ messages: QueueMessage[] }>("GET", "/api/worker/queue?platform=twitch");
    for (const message of result.messages ?? []) {
      try {
        const context = await contextFor(message.channel_id);
        const response = await fetch("https://api.twitch.tv/helix/chat/messages", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "Client-Id": context.client_id,
            Authorization: `Bearer ${context.access_token}`,
          },
          body: JSON.stringify({
            broadcaster_id: context.broadcaster_user_id,
            sender_id: context.sender_user_id,
            message: message.message_text.slice(0, 500),
            ...(message.reply_to_message_id ? { reply_parent_message_id: message.reply_to_message_id } : {}),
          }),
          signal: AbortSignal.timeout(5_000),
        });
        const payload = await response.json().catch(() => ({})) as { data?: Array<{ is_sent?: boolean; drop_reason?: { message?: string } }> };
        const sent = response.ok && payload.data?.[0]?.is_sent === true;
        if (!sent) throw new Error(payload.data?.[0]?.drop_reason?.message || `Twitch HTTP ${response.status}`);
        await signedRequest("POST", `/api/worker/messages/${message.id}/ack`, { status: "sent", attempts: message.attempts });
      } catch (error) {
        const detail = error instanceof Error ? error.message : "Envoi Twitch indisponible";
        if (/401|403|token|auth/i.test(detail)) contextCache.delete(message.channel_id);
        await signedRequest("POST", `/api/worker/messages/${message.id}/ack`, { status: message.attempts >= 5 ? "failed" : "retry", error: detail.slice(0, 500), attempts: message.attempts }).catch(() => undefined);
      }
    }
  } finally {
    queueBusy = false;
  }
};

// Viewer count is the one live-widget value not supplied by the chat EventSub
// topics. Twitch's official Streams endpoint is inexpensive at this cadence
// and is queried only for already connected Twitch profiles.
let viewerCountBusy = false;
const pollViewerCounts = async (): Promise<void> => {
  if (viewerCountBusy) return;
  viewerCountBusy = true;
  try {
    const events: EngineEvent[] = [];
    for (const channel of twitchChannels.values()) {
      if (!channel.twitch_user_id) continue;
      try {
        const context = await contextFor(channel.channel_id);
        const response = await fetch(`https://api.twitch.tv/helix/streams?user_id=${encodeURIComponent(channel.twitch_user_id)}`, {
          headers: {
            "Client-Id": context.client_id,
            Authorization: `Bearer ${context.access_token}`,
          },
          signal: AbortSignal.timeout(5_000),
        });
        if (!response.ok) continue;
        const payload = await response.json().catch(() => ({})) as { data?: Array<{ viewer_count?: unknown }> };
        const count = Number(payload.data?.[0]?.viewer_count ?? 0);
        if (!Number.isFinite(count) || count < 1) continue;
        events.push({
          id: `twitch:viewers:${channel.channel_id}:${Math.floor(Date.now() / 25_000)}`,
          channel_id: channel.channel_id,
          type: "livestream.viewer_count",
          platform: "twitch",
          payload: { viewer_count: Math.floor(count), source: "twitch" },
        });
      } catch {
        // A single temporary Helix failure must not impact chat handling.
      }
    }
    if (events.length) await signedRequest("POST", "/api/worker/events", { events });
  } finally {
    viewerCountBusy = false;
  }
};

console.info("FilyX Twitch Engine prêt (EventSub officiel + chat Helix).");
// Twitch chat reaches us through EventSub: it does not need to hammer the
// shared PHP panel every 300 ms while the inbox is empty.  Keeping these
// values bounded still feels immediate, while preserving capacity for the
// Kick engine (the primary real-time path).
const TWITCH_INBOUND_POLL_MS = 650;
const TWITCH_OUTBOUND_POLL_MS = 450;
const TWITCH_GAME_SYNC_MS = 5_000;
// These timers deliberately keep the process alive. A Twitch engine without
// an HTTP server has no other active handle; using `unref()` here would make
// systemd restart it forever before it could process a single EventSub event.
setInterval(() => { void pollInbound().catch(() => undefined); }, TWITCH_INBOUND_POLL_MS);
setInterval(() => { void pollOutbound().catch(() => undefined); }, TWITCH_OUTBOUND_POLL_MS);
setInterval(() => { void syncGames().catch(() => undefined); }, TWITCH_GAME_SYNC_MS);
setInterval(() => { void pollViewerCounts().catch(() => undefined); }, 25_000);
const heartbeat = async (): Promise<void> => {
  await signedRequest("POST", "/api/worker/heartbeat", {
    version: "twitch-engine-0.1",
    metadata: { platform: "twitch", event_transport: "eventsub-webhook" },
    active_channels: twitchChannels.size,
    websocket_connections: 0,
    queue_depth: contextCache.size,
  });
};
setInterval(() => { void heartbeat().catch(() => undefined); }, 15_000);
void pollInbound().catch(() => undefined);
void pollOutbound().catch(() => undefined);
void syncGames().catch(() => undefined);
void pollViewerCounts().catch(() => undefined);
void heartbeat().catch(() => undefined);
