import { hashArgs, bytesToHex } from '../protocol/canonical.js';
import { IdempotencyConflictError } from '../schemas/errors.js';

interface Entry {
  argsHash: string;
  /** `undefined` while inflight; resolved value once handler completes. */
  status: 'inflight' | 'done';
  promise?: Promise<unknown>;
  response?: unknown;
  insertedAt: number;
  lastAccessAt: number;
}

export interface IdempotencyCacheOptions {
  /** Time in ms after which an entry is eligible for eviction. Default 1h. */
  ttlMs?: number;
  /** Per-sender hard cap on cached entries; oldest-by-insert evicted first. */
  maxEntriesPerSender?: number;
}

/**
 * Per-sender LRU + TTL cache for idempotent RPC calls.
 *
 *  - On lookup, returns cached response if `argsHash` matches the original
 *    call. Throws `IdempotencyConflictError` on argsHash mismatch.
 *  - On concurrent retry of the same key (still inflight), the second
 *    caller awaits the first's promise — no double-execution.
 *  - LRU eviction caps memory at `maxEntriesPerSender` per sender.
 *
 * Keyed on `(senderAddress, idempotencyKey)`. Internally a 2-level map.
 */
export class IdempotencyCache {
  private readonly ttlMs: number;
  private readonly maxPerSender: number;
  private readonly bySender = new Map<string, Map<string, Entry>>();

  constructor(opts: IdempotencyCacheOptions = {}) {
    this.ttlMs = opts.ttlMs ?? 60 * 60 * 1000;
    this.maxPerSender = opts.maxEntriesPerSender ?? 10_000;
  }

  /**
   * Begin (or rejoin) an idempotent operation. Returns:
   *  - `{ status: 'fresh', commit }` — handler should run, then call `commit(response)`.
   *  - `{ status: 'wait', promise }` — concurrent retry; await the in-flight promise.
   *  - `{ status: 'replay', response }` — completed earlier; return cached.
   *
   * Mismatched argsHash throws `IdempotencyConflictError` immediately.
   */
  begin(
    sender: string,
    key: string,
    args: unknown,
  ):
    | { status: 'fresh'; commit: (response: unknown) => void; abandon: () => void }
    | { status: 'wait'; promise: Promise<unknown> }
    | { status: 'replay'; response: unknown } {
    const argsHash = bytesToHex(hashArgs(args));
    const senderMap = this.getOrCreate(sender);
    this.evictExpired(senderMap);
    const existing = senderMap.get(key);
    if (existing !== undefined) {
      if (existing.argsHash !== argsHash) {
        throw new IdempotencyConflictError();
      }
      existing.lastAccessAt = Date.now();
      if (existing.status === 'done') {
        return { status: 'replay', response: existing.response };
      }
      // Inflight — return the same promise so concurrent retries de-dupe.
      return { status: 'wait', promise: existing.promise! };
    }

    let resolveInflight: (value: unknown) => void = () => {};
    let rejectInflight: (err: unknown) => void = () => {};
    const inflightPromise = new Promise<unknown>((resolve, reject) => {
      resolveInflight = resolve;
      rejectInflight = reject;
    });
    // Suppress unhandled-rejection warnings when no concurrent retry is
    // awaiting this promise. Real waiters attach their own catch via
    // `await begin().promise` and still see the rejection.
    inflightPromise.catch(() => {
      /* swallow — see comment above */
    });
    const entry: Entry = {
      argsHash,
      status: 'inflight',
      promise: inflightPromise,
      insertedAt: Date.now(),
      lastAccessAt: Date.now(),
    };
    senderMap.set(key, entry);
    this.evictOverflow(senderMap);

    const commit = (response: unknown): void => {
      entry.status = 'done';
      entry.response = response;
      entry.lastAccessAt = Date.now();
      resolveInflight(response);
    };
    const abandon = (): void => {
      // Failed/cancelled call — remove from cache so retries can proceed.
      senderMap.delete(key);
      rejectInflight(new Error('idempotency abandoned'));
    };

    return { status: 'fresh', commit, abandon };
  }

  /** Manual prune; called by the periodic retention job. */
  prune(now: number = Date.now()): number {
    let removed = 0;
    for (const [sender, senderMap] of this.bySender) {
      for (const [key, entry] of senderMap) {
        if (now - entry.insertedAt > this.ttlMs) {
          senderMap.delete(key);
          removed++;
        }
      }
      if (senderMap.size === 0) this.bySender.delete(sender);
    }
    return removed;
  }

  /** Total cached entries across all senders (mainly for tests/metrics). */
  size(): number {
    let total = 0;
    for (const m of this.bySender.values()) total += m.size;
    return total;
  }

  private getOrCreate(sender: string): Map<string, Entry> {
    let m = this.bySender.get(sender);
    if (m === undefined) {
      m = new Map();
      this.bySender.set(sender, m);
    }
    return m;
  }

  private evictExpired(senderMap: Map<string, Entry>): void {
    const now = Date.now();
    for (const [key, entry] of senderMap) {
      if (now - entry.insertedAt > this.ttlMs) senderMap.delete(key);
    }
  }

  private evictOverflow(senderMap: Map<string, Entry>): void {
    if (senderMap.size <= this.maxPerSender) return;
    // Map iteration order = insertion order; first key is oldest.
    const overflow = senderMap.size - this.maxPerSender;
    let i = 0;
    for (const key of senderMap.keys()) {
      if (i >= overflow) break;
      senderMap.delete(key);
      i++;
    }
  }
}
