import { createHash } from "node:crypto";
import { createRequire } from "node:module";
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { BotConfig } from "../config.ts";
import type { ChannelStatus, ChatMessage, EngineEvent } from "../types.ts";
import type { ActiveGame } from "./GameRouter.ts";

type LegacyEngine = {
  start(config: Record<string, unknown>, startedBy: string): { ok: boolean; error?: string };
  stop(): unknown;
  setStreamerId?(id: string): void;
  submitAnswer?(player: string, answer: string): unknown;
  submitGuess?(player: string, answer: string): unknown;
};

type Instance = {
  game: ActiveGame;
  channel: ChannelStatus;
  engine: LegacyEngine;
  viewers: Map<string, { kick_user_id: string; username: string }>;
};

type GameLease = (
  channelId: number,
  gameSessionId: number,
  action: "claim" | "renew" | "release"
) => Promise<boolean>;

const require = createRequire(import.meta.url);

export class LegacyGameCoordinator {
  private readonly config: BotConfig;
  private readonly emit: (event: EngineEvent) => void;
  private readonly leaseGame: GameLease | null;
  // The API can serialise database ids as strings while a Kick webhook uses a
  // number.  Canonical string keys keep the same live instance in both cases.
  private readonly instances = new Map<string, Instance>();
  private readonly instancesBySession = new Map<string, Instance>();
  // A session can remain "starting" during several refreshes. This lock
  // prevents the same historical engine from being started twice.
  private readonly startingSessions = new Set<string>();
  // Once a session has started in this process it must never be recreated.
  // It is cleared only when the server has confirmed the session is gone.
  private readonly startedSessions = new Set<string>();
  private readonly missingGameSyncs = new Map<string, number>();

  constructor(config: BotConfig, emit: (event: EngineEvent) => void, leaseGame: GameLease | null = null) {
    this.config = config;
    this.emit = emit;
    this.leaseGame = leaseGame;
  }

  async sync(channel: ChannelStatus, game: ActiveGame | null): Promise<void> {
    const channelId = Number(channel.channel_id);
    const channelKey = this.channelKey(channelId);
    const gameKey = game ? this.gameKey(channelId, game.game_session_id) : "";
    const current = this.instances.get(channelKey) ?? (game ? this.instancesBySession.get(gameKey) : undefined);
    if (!game) {
      // A short network/API blip used to be treated as an end of game. That
      // stopped the first engine, then the following refresh created a second
      // one in "Reprise" mode. Require three consecutive empty snapshots.
      const missingCount = (this.missingGameSyncs.get(channelKey) ?? 0) + 1;
      this.missingGameSyncs.set(channelKey, missingCount);
      if (missingCount < 3) return;
      this.missingGameSyncs.delete(channelKey);
      current?.engine.stop();
      this.instances.delete(channelKey);
      if (current) {
        const currentGameKey = this.gameKey(channelId, current.game.game_session_id);
        this.instancesBySession.delete(currentGameKey);
        this.startedSessions.delete(currentGameKey);
      }
      if (current && this.leaseGame) {
        await this.leaseGame(channelId, Number(current.game.game_session_id), "release").catch(() => false);
      }
      return;
    }
    this.missingGameSyncs.delete(channelKey);
    if (current && this.gameKey(channelId, current.game.game_session_id) === gameKey) {
      // Restore the channel lookup if a previous response used the other id
      // representation (for example "1" then 1).
      this.instances.set(channelKey, current);
      if (this.leaseGame && !(await this.leaseGame(channelId, Number(game.game_session_id), "renew").catch(() => false))) {
        current.engine.stop();
        this.instances.delete(channelKey);
        this.instancesBySession.delete(gameKey);
      }
      return;
    }
    if (this.startingSessions.has(gameKey)) return;
    // Never replay the same session in the same engine process. The live
    // instance is kept above during a temporary missing panel response.
    if (this.startedSessions.has(gameKey)) return;

    this.startingSessions.add(gameKey);
    current?.engine.stop();
    this.instances.delete(channelKey);
    if (current) this.instancesBySession.delete(this.gameKey(channelId, current.game.game_session_id));
    try {
      // The lease is the cross-process lock.  A running or stale duplicate
      // engine must never be allowed to create a second round for one session.
      if (this.leaseGame && !(await this.leaseGame(channelId, Number(game.game_session_id), "claim").catch(() => false))) {
        return;
      }
      const instance = await this.create(channel, game);
      // A newer game may have taken this channel while its settings loaded.
      if (!this.startingSessions.has(gameKey)) {
        instance.engine.stop();
        if (this.leaseGame) await this.leaseGame(channelId, Number(game.game_session_id), "release").catch(() => false);
        return;
      }
      this.instances.set(channelKey, instance);
      this.instancesBySession.set(gameKey, instance);
      const settings = this.mergePanelSettings(await this.loadSettings(game.game), game);
      const result = instance.engine.start(settings, game.state === "starting" ? "Panel FilyX" : "Reprise FilyX");
      if (!result.ok) {
        this.emit({
          id: `game-failed:${game.game_session_id}:${Date.now()}`,
          channel_id: channelId,
          type: "game.session.failed",
          payload: { game_session_id: game.game_session_id, error: result.error ?? "Démarrage refusé" }
        });
        this.instances.delete(channelKey);
        this.instancesBySession.delete(gameKey);
        if (this.leaseGame) await this.leaseGame(channelId, Number(game.game_session_id), "release").catch(() => false);
        return;
      }
      this.startedSessions.add(gameKey);
      this.emit({
        id: `game-started:${game.game_session_id}`,
        channel_id: channelId,
        type: "game.session.started",
        payload: { game_session_id: game.game_session_id, game: game.game }
      });
    } finally {
      this.startingSessions.delete(gameKey);
    }
  }

  handleMessage(message: ChatMessage): void {
    const instance = this.instances.get(this.channelKey(message.channel_id));
    if (!instance || message.content.startsWith("!")) return;
    const usernameKey = message.sender.username.toLocaleLowerCase("fr");
    instance.viewers.set(usernameKey, {
      kick_user_id: message.sender.kick_user_id,
      username: message.sender.username
    });
    if (instance.game.game === "quiz" || instance.game.game === "petitbac") {
      instance.engine.submitAnswer?.(message.sender.username, message.content.trim());
    } else {
      instance.engine.submitGuess?.(message.sender.username, message.content.trim());
    }
  }

  /**
   * A confirmed !stop is not a transient panel snapshot.  Stop the in-memory
   * timer straight away instead of waiting for the three-snapshot safety
   * window used by regular background synchronisation.
   */
  async stopChannel(channelId: number): Promise<void> {
    const channelKey = this.channelKey(channelId);
    const current = this.instances.get(channelKey);
    if (!current) return;
    current.engine.stop();
    this.instances.delete(channelKey);
    const gameKey = this.gameKey(channelId, current.game.game_session_id);
    this.instancesBySession.delete(gameKey);
    this.startedSessions.delete(gameKey);
    this.missingGameSyncs.delete(channelKey);
    if (this.leaseGame) {
      await this.leaseGame(channelId, Number(current.game.game_session_id), "release").catch(() => false);
    }
  }

  stop(): void {
    for (const instance of this.instances.values()) instance.engine.stop();
    this.instances.clear();
    this.instancesBySession.clear();
    this.startingSessions.clear();
    this.startedSessions.clear();
    this.missingGameSyncs.clear();
  }

  private channelKey(channelId: number | string): string {
    return String(Number(channelId));
  }

  private gameKey(channelId: number | string, gameSessionId: number | string): string {
    return `${this.channelKey(channelId)}:${Number(gameSessionId)}`;
  }

  private async create(channel: ChannelStatus, game: ActiveGame): Promise<Instance> {
    const source = path.join(this.config.legacyRoot, "games-original", "src");
    const modules = {
      quiz: { file: "quiz-engine.js", exportName: "QuizEngine" },
      motus: { file: "motus-engine.js", exportName: "MotusEngine" },
      whoami: { file: "whoami-engine.js", exportName: "WhoamiEngine" },
      petitbac: { file: "petitbac-engine.js", exportName: "PetitbacEngine" }
    } as const;
    const target = modules[game.game];
    const loaded = require(path.join(source, target.file)) as Record<string, new (...args: unknown[]) => LegacyEngine>;
    const Engine = loaded[target.exportName];
    if (!Engine) throw new Error(`Moteur historique introuvable : ${game.game}`);
    const viewers = new Map<string, { kick_user_id: string; username: string }>();
    let lastBroadcast: Record<string, unknown> = {};
    // The legacy engines emit a provisional state after many rounds. That
    // state has no round number, so it must not erase the last known one when
    // we create the idempotency key for a chat message.
    let currentRound = 0;
    const broadcast = (payload: Record<string, unknown>): void => {
      const reportedRound = Number(
        payload.index ?? payload.round ?? payload.current ?? payload.categoryIndex ?? payload.wordIndex ?? 0
      );
      if (Number.isFinite(reportedRound) && reportedRound > 0) currentRound = Math.floor(reportedRound);
      const scores = this.enrichScores(payload.scores, viewers);
      const data = scores ? { ...payload, scores } : payload;
      lastBroadcast = data;
      this.emit({
        id: `overlay:${game.game_session_id}:${Date.now()}:${Math.random().toString(16).slice(2)}`,
        channel_id: channel.channel_id,
        type: "overlay.event",
        payload: {
          event_type: `game.${game.game}.${String(payload.type ?? "state")}`,
          data: { ...data, game_session_id: game.game_session_id },
          priority: 3,
          is_demo: false
        }
      });
      if (scores) {
        this.emit({
          id: `scores:${game.game_session_id}:${Date.now()}`,
          channel_id: channel.channel_id,
          type: "game.scores",
          payload: { game_session_id: game.game_session_id, scores }
        });
      }
      if (this.isContentEvent(String(payload.type ?? ""))) {
        this.emit({
          id: `content:${game.game_session_id}:${Date.now()}:${Math.random().toString(16).slice(2)}`,
          channel_id: channel.channel_id,
          type: "game.content",
          payload: {
            game_session_id: game.game_session_id,
            game: game.game,
            content_type: String(payload.type ?? "content"),
            content: data
          }
        });
      }
      if (this.isEndEvent(String(payload.type ?? ""))) {
        this.emit({
          id: `game-completed:${game.game_session_id}`,
          channel_id: channel.channel_id,
          type: "game.session.completed",
          payload: { game_session_id: game.game_session_id }
        });
      }
    };
    const chatSend = (text: string, messageKey = "result"): void => {
      const outgoingText = this.rebrand(text);
      const round = Math.max(
        currentRound,
        Number(lastBroadcast.index ?? lastBroadcast.round ?? lastBroadcast.current ?? lastBroadcast.categoryIndex ?? lastBroadcast.wordIndex ?? 0) || 0
      );
      // A game reuses keys such as "clue", "answer" or "found" for every
      // round. Include the rendered text fingerprint so only an identical
      // retry is deduplicated, never the next question or answer.
      const fingerprint = createHash("sha256").update(outgoingText).digest("hex").slice(0, 16);
      this.emit({
        id: `game-chat:${game.game_session_id}:${Date.now()}:${Math.random().toString(16).slice(2)}`,
        channel_id: channel.channel_id,
        type: "game.chat",
        payload: {
          game_session_id: game.game_session_id,
          game: game.game,
          text: outgoingText,
          message_key: messageKey,
          // Stable across a retry or an accidental duplicate engine event.
          dedupe_key: `${game.game}:${game.game_session_id}:${messageKey}:${round}:${fingerprint}`
        }
      });
    };
    const engine = new Engine(broadcast, chatSend);
    engine.setStreamerId?.(String(channel.channel_id));
    return { game, channel, engine, viewers };
  }

  private async loadSettings(game: ActiveGame["game"]): Promise<Record<string, unknown>> {
    const file = path.join(this.config.legacyRoot, "games-original", `config.${game}.json`);
    const parsed = JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
    return this.deepRebrand(parsed) as Record<string, unknown>;
  }

  private mergePanelSettings(base: Record<string, unknown>, game: ActiveGame): Record<string, unknown> {
    const panel = game.settings ?? {};
    const rounds = this.numberSetting(panel.rounds);
    const duration = this.numberSetting(panel.round_duration_seconds);
    const cooldown = this.numberSetting(panel.cooldown_seconds);
    const points = this.numberSetting(panel.points_per_win);
    const volume = this.numberSetting(panel.volume);
    const appearanceMap: Record<string, string> = {
      title: "title", primary_color: "primaryColor", secondary_color: "secondaryColor",
      color_correct: "colorCorrect", color_misplaced: "colorMisplaced", color_absent: "colorAbsent",
      show_category: "showCategory", show_difficulty: "showDifficulty", show_counter: "showCounter",
      show_provisional: "showProvisional", show_explanation: "showExplanation", show_participants: "showParticipants",
      show_pseudo: "showPseudo", show_proposals: "showProposals", show_username: "showUsername",
      show_queue_counter: "showQueueCounter", show_total_proposals: "showTotalProposals",
      animations: "animations", sound_enabled: "soundEnabled", animation_speed: "animationSpeed",
      max_username_length: "maxUsernameLength", display_duration: "displayDuration",
      visible_proposals: "visibleProposals", proposal_position: "proposalPosition", group_identical: "groupIdentical"
    };
    const panelAppearance = Object.fromEntries(Object.entries(appearanceMap)
      .filter(([source]) => panel[source] !== undefined)
      .map(([source, target]) => [target, panel[source]]));
    const appearance = {
      ...((base.appearance && typeof base.appearance === "object") ? base.appearance as Record<string, unknown> : {}),
      ...panelAppearance,
      ...(volume === null ? {} : { soundVolume: Math.max(0, Math.min(1, volume / 100)) })
    };
    const messages = {
      ...((base.messages && typeof base.messages === "object") ? base.messages as Record<string, unknown> : {}),
      ...this.messageOverrides(game.game, panel)
    };
    // Les modèles restent enregistrés dans le panel, mais un message décoché
    // n'est jamais transmis au moteur donc ne peut pas être envoyé dans Kick.
    for (const [source, target] of Object.entries(this.messageMap(game.game))) {
      if (!this.panelMessageEnabled(panel, source)) delete messages[target];
    }
    const shared = {
      ...base,
      appearance,
      messages,
      ...(panel.show_category === undefined ? {} : { showCategory: Boolean(Number(panel.show_category)) }),
      ...(panel.show_provisional === undefined ? {} : { showProvisional: Boolean(Number(panel.show_provisional)) }),
      ...(panel.show_proposals === undefined ? {} : { showProposals: Boolean(Number(panel.show_proposals)) }),
      ...(panel.animations === undefined ? {} : { animations: Boolean(Number(panel.animations)) }),
      ...(panel.sound_enabled === undefined ? {} : { soundEnabled: Boolean(Number(panel.sound_enabled)) })
    };

    if (game.game === "quiz") {
      return { ...shared, ...(rounds === null ? {} : { questionsPerGame: rounds }), ...(duration === null ? {} : { questionTime: duration }), ...(cooldown === null ? {} : { cooldown }), ...(points === null ? {} : { pointsCorrect: points }), answerTime: Number(panel.answer_time ?? base.answerTime ?? 15), podiumTime: Number(panel.podium_time ?? base.podiumTime ?? 20), firstBonus: Boolean(Number(panel.first_bonus ?? base.firstBonus ?? 1)), firstBonusAmount: Number(panel.first_bonus_amount ?? base.firstBonusAmount ?? 20), speedBonus: Boolean(Number(panel.speed_bonus ?? base.speedBonus ?? 0)), singleAnswer: Boolean(Number(panel.single_answer ?? base.singleAnswer ?? 1)), multiAttempt: Boolean(Number(panel.multi_attempt ?? base.multiAttempt ?? 0)) };
    }
    if (game.game === "motus") {
      return { ...shared, ...(rounds === null ? {} : { wordsPerGame: rounds }), ...(duration === null ? {} : { roundTime: duration }), ...(cooldown === null ? {} : { gameCooldown: cooldown }), ...(points === null ? {} : { pointsFound: points }), maxAttempts: Number(panel.max_attempts ?? base.maxAttempts ?? 10), solutionTime: Number(panel.solution_time ?? base.solutionTime ?? 5), betweenRounds: Number(panel.between_rounds ?? base.betweenRounds ?? 3), viewerCooldown: Number(panel.viewer_cooldown ?? base.viewerCooldown ?? 3) };
    }
    if (game.game === "whoami") {
      const clueInterval = duration === null ? null : Math.max(3, Math.round(duration / 5));
      const pointsPerClue = points === null ? null : [5, 4, 3, 2, 1].map((factor) => points * factor);
      return { ...shared, ...(rounds === null ? {} : { roundsPerGame: rounds }), ...(clueInterval === null ? {} : { clueInterval }), ...(cooldown === null ? {} : { gameCooldown: cooldown }), ...(pointsPerClue === null ? {} : { pointsPerClue }), clueCount: Number(panel.clue_count ?? base.clueCount ?? 5), victoryTime: Number(panel.victory_time ?? base.victoryTime ?? 7), podiumTime: Number(panel.podium_time ?? base.podiumTime ?? 10), tolerance: Boolean(Number(panel.tolerance ?? base.tolerance ?? 0)) };
    }
    return { ...shared, ...(rounds === null ? {} : { categoriesPerGame: rounds }), ...(duration === null ? {} : { categoryTime: duration }), ...(cooldown === null ? {} : { cooldown }), ...(points === null ? {} : { pointsFirst: points }), revealTime: Number(panel.reveal_time ?? base.revealTime ?? 15), podiumTime: Number(panel.podium_time ?? base.podiumTime ?? 10), pointsSecond: Number(panel.points_second ?? base.pointsSecond ?? 200), pointsThird: Number(panel.points_third ?? base.pointsThird ?? 100), pointsOther: Number(panel.points_other ?? base.pointsOther ?? 50), uniqueBonus: Number(panel.unique_bonus ?? base.uniqueBonus ?? 100), hideAnswers: Boolean(Number(panel.hide_answers ?? base.hideAnswers ?? 1)), allowEdit: Boolean(Number(panel.allow_edit ?? base.allowEdit ?? 0)) };
  }

  private messageOverrides(game: ActiveGame["game"], panel: Record<string, unknown>): Record<string, string> {
    const maps = this.messageMap(game);
    return Object.fromEntries(Object.entries(maps)
      .filter(([source]) => panel[source] !== undefined)
      .map(([source, target]) => [target, this.rebrand(String(panel[source] ?? "").trim()).slice(0, 500)]));
  }

  private messageMap(game: ActiveGame["game"]): Record<string, string> {
    const maps: Record<ActiveGame["game"], Record<string, string>> = {
      quiz: {
        message_launch: "launch", message_answer: "answer", message_podium: "podium"
      },
      motus: {
        message_launch: "launch", message_round: "round", message_found: "found",
        message_multi_found: "multiFound", message_not_found: "notFound",
        message_end: "end", message_podium: "podium"
      },
      whoami: {
        message_launch: "launch", message_round_transition: "roundTransition", message_clue: "clue",
        message_winner: "winner", message_no_winner: "noWinner",
        message_end: "end", message_podium: "podium"
      },
      petitbac: {
        message_launch: "launch", message_category: "category", message_stop: "stop",
        message_results: "results", message_no_answers: "noAnswers", message_unique: "unique",
        message_end: "end", message_podium: "podium"
      }
    };
    return maps[game];
  }

  private panelMessageEnabled(panel: Record<string, unknown>, messageKey: string): boolean {
    const field = `chat_${messageKey}_enabled`;
    return panel[field] === undefined || Boolean(Number(panel[field]));
  }

  private numberSetting(value: unknown): number | null {
    const number = typeof value === "number" ? value : Number(value);
    return Number.isFinite(number) ? number : null;
  }

  private deepRebrand(value: unknown): unknown {
    if (typeof value === "string") return this.rebrand(value);
    if (Array.isArray(value)) return value.map((item) => this.deepRebrand(item));
    if (value && typeof value === "object") {
      return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, this.deepRebrand(item)]));
    }
    return value;
  }

  private rebrand(value: string): string {
    return value
      .replace(/\[emote:[^\]]+\]\s*/g, "")
      .replace(/HR3D(?:_STUDIO|-Studio| Studio)?/gi, "FilyX")
      .replace(/Le Motus de Filyx/gi, "Motus FilyX");
  }

  private enrichScores(value: unknown, viewers: Map<string, { kick_user_id: string; username: string }>): unknown[] | null {
    if (!Array.isArray(value)) return null;
    return value.map((entry) => {
      if (!entry || typeof entry !== "object") return entry;
      const score = entry as Record<string, unknown>;
      const username = String(score.player ?? score.username ?? "");
      const viewer = viewers.get(username.toLocaleLowerCase("fr"));
      return { ...score, username, kick_user_id: viewer?.kick_user_id ?? "" };
    });
  }

  private isEndEvent(type: string): boolean {
    return ["quiz-podium", "motus-end", "whoami-podium", "petitbac-podium"].includes(type);
  }

  private isContentEvent(type: string): boolean {
    return [
      "quiz-question",
      "quiz-answer",
      "motus-round-start",
      "motus-word-reveal",
      "whoami-round-start",
      "whoami-round-end",
      "petitbac-start",
      "petitbac-category-start"
    ].includes(type);
  }
}
