export interface MessageBatcherOptions {
  /** Default: 600. */
  readonly debounceMs?: number;
  /** Default: 8. */
  readonly maxMessages?: number;
  /** Default: 4000. */
  readonly maxChars?: number;
  /** Default: 3500. */
  readonly splitThreshold?: number;
  /** Default: 1200. */
  readonly extendedDebounceMs?: number;
}

export type MessageBatcherCallback = (mergedText: string, key?: string) => Promise<void>;

interface PendingBatch {
  readonly texts: string[];
  charCount: number;
  timer: ReturnType<typeof setTimeout> | null;
}

const DEFAULT_OPTIONS = {
  debounceMs: 600,
  maxMessages: 8,
  maxChars: 4000,
  splitThreshold: 3500,
  extendedDebounceMs: 1200,
} as const;

export class MessageBatcher {
  private readonly debounceMs: number;
  private readonly maxMessages: number;
  private readonly maxChars: number;
  private readonly splitThreshold: number;
  private readonly extendedDebounceMs: number;
  private readonly pending = new Map<string, PendingBatch>();
  private readonly chains = new Map<string, Promise<void>>();

  constructor(
    private readonly onFlush: MessageBatcherCallback,
    options: MessageBatcherOptions = {},
  ) {
    this.debounceMs = options.debounceMs ?? DEFAULT_OPTIONS.debounceMs;
    this.maxMessages = options.maxMessages ?? DEFAULT_OPTIONS.maxMessages;
    this.maxChars = options.maxChars ?? DEFAULT_OPTIONS.maxChars;
    this.splitThreshold = options.splitThreshold ?? DEFAULT_OPTIONS.splitThreshold;
    this.extendedDebounceMs = options.extendedDebounceMs ?? DEFAULT_OPTIONS.extendedDebounceMs;
  }

  async enqueue(chatId: string, senderOpenId: string, text: string, discriminator?: string): Promise<void> {
    const key = messageBatchKey(chatId, senderOpenId, discriminator);
    await this.runSerial(key, async () => {
      const existing = this.pending.get(key);
      const batch =
        existing ??
        {
          texts: [],
          charCount: 0,
          timer: null,
        };

      if (existing === undefined) {
        this.pending.set(key, batch);
      }

      batch.texts.push(text);
      batch.charCount += text.length + (batch.texts.length === 1 ? 0 : 1);

      if (batch.texts.length >= this.maxMessages || batch.charCount >= this.maxChars) {
        await this.flushPendingBatch(key, batch);
        return;
      }

      this.scheduleFlush(key, batch, text.length >= this.splitThreshold ? this.extendedDebounceMs : this.debounceMs);
    });
  }

  async flushNow(key: string): Promise<void> {
    await this.runSerial(key, async () => {
      const batch = this.pending.get(key);
      if (batch === undefined) return;
      await this.flushPendingBatch(key, batch);
    });
  }

  async flushAll(): Promise<void> {
    const keys = [...this.pending.keys()];
    await Promise.all(keys.map((key) => this.flushNow(key)));
  }

  private scheduleFlush(key: string, batch: PendingBatch, delayMs: number): void {
    if (batch.timer !== null) {
      clearTimeout(batch.timer);
    }
    batch.timer = setTimeout(() => {
      void this.flushNow(key).catch(() => undefined);
    }, delayMs);
    (batch.timer as { unref?: () => void }).unref?.();
  }

  private async flushPendingBatch(key: string, batch: PendingBatch): Promise<void> {
    if (batch.timer !== null) {
      clearTimeout(batch.timer);
      batch.timer = null;
    }
    if (this.pending.get(key) !== batch) return;
    this.pending.delete(key);
    await this.onFlush(batch.texts.join("\n"), key);
  }

  private runSerial(key: string, operation: () => Promise<void>): Promise<void> {
    const previous = this.chains.get(key) ?? Promise.resolve();
    const next = previous.catch(() => undefined).then(operation);
    this.chains.set(key, next);
    return next.finally(() => {
      if (this.chains.get(key) === next) {
        this.chains.delete(key);
      }
    });
  }
}

export function messageBatchKey(chatId: string, senderOpenId: string, discriminator?: string): string {
  return discriminator === undefined
    ? `${chatId}:${senderOpenId}`
    : `${chatId}:${senderOpenId}:${discriminator}`;
}
