import type { Message } from "@slackwsh/contracts";

const DEFAULT_MESSAGES_PER_CHANNEL = 500;
const DEFAULT_MAX_CHANNELS = 50;

/**
 * The entity cache for messages (§5.3 "Local cache" / ADR-004's rejection of
 * TanStack Query as the primary store — this is deliberately *not* a
 * request cache). One instance per client; holds a bounded window per
 * channel, LRU-evicted at the channel level.
 *
 * Invariants owned here:
 *  - I1 (total order): `getMessages` always returns seq-ascending.
 *  - I3 (idempotency): `upsert` keyed by message `id` is a no-op replace,
 *    never a duplicate — the server is what enforces uniqueness on
 *    (channel_id, client_msg_id); this store just never renders the same
 *    id twice regardless of how many times it arrives.
 *  - I8 (mutation convergence): edits/deletes/reactions apply only if the
 *    incoming revision is >= what's cached, so out-of-order delivery of an
 *    edit followed by a stale re-delivery of the pre-edit state can't undo
 *    a newer edit.
 */
export class MessageStore {
  private readonly channels = new Map<string, Map<string, Message>>();
  private readonly channelOrder: string[] = []; // LRU order, most-recent last
  private readonly messagesPerChannel: number;
  private readonly maxChannels: number;

  constructor(opts: { messagesPerChannel?: number; maxChannels?: number } = {}) {
    this.messagesPerChannel = opts.messagesPerChannel ?? DEFAULT_MESSAGES_PER_CHANNEL;
    this.maxChannels = opts.maxChannels ?? DEFAULT_MAX_CHANNELS;
  }

  upsert(channelId: string, message: Message): void {
    const byId = this.touchChannel(channelId);
    const key = String(message.id);
    const existing = byId.get(key);
    if (existing && existing.revision > message.revision) return; // I8: never regress
    byId.set(key, message);
    this.trimToWindow(byId);
  }

  applyDelete(channelId: string, messageId: string | number, seq: number): void {
    const byId = this.channels.get(channelId);
    const key = String(messageId);
    const existing = byId?.get(key);
    if (!existing) return;
    if (existing.deletedAt) return; // already applied
    byId!.set(key, {
      ...existing,
      seq,
      text: "",
      revision: existing.revision + 1,
      deletedAt: new Date().toISOString(),
    });
  }

  /** Always seq-ascending (I1) — never sorted by arrival or wall-clock time. */
  getMessages(channelId: string): Message[] {
    const byId = this.channels.get(channelId);
    if (!byId) return [];
    return Array.from(byId.values()).sort((a, b) => a.seq - b.seq);
  }

  getMessage(channelId: string, messageId: string | number): Message | undefined {
    return this.channels.get(channelId)?.get(String(messageId));
  }

  clearChannel(channelId: string): void {
    this.channels.delete(channelId);
    const idx = this.channelOrder.indexOf(channelId);
    if (idx >= 0) this.channelOrder.splice(idx, 1);
  }

  private touchChannel(channelId: string): Map<string, Message> {
    let byId = this.channels.get(channelId);
    if (!byId) {
      byId = new Map();
      this.channels.set(channelId, byId);
    }
    const idx = this.channelOrder.indexOf(channelId);
    if (idx >= 0) this.channelOrder.splice(idx, 1);
    this.channelOrder.push(channelId);

    while (this.channelOrder.length > this.maxChannels) {
      const evicted = this.channelOrder.shift();
      if (evicted) this.channels.delete(evicted);
    }
    return byId;
  }

  /** Keeps only the most recent N messages by seq — "bounded window per
   * channel" (§5.3). Evicting by lowest seq is safe: catch-up (I7) always
   * re-fetches from the server's authoritative log, never from this cache
   * alone, so trimming old entries never fabricates a gap the client can't
   * recover from. */
  private trimToWindow(byId: Map<string, Message>): void {
    if (byId.size <= this.messagesPerChannel) return;
    const sorted = Array.from(byId.values()).sort((a, b) => a.seq - b.seq);
    const toEvict = sorted.slice(0, sorted.length - this.messagesPerChannel);
    for (const m of toEvict) byId.delete(String(m.id));
  }
}
