import { timingSafeEqual } from 'node:crypto';

import type { EventHandler, HandlerResult, Transaction } from './types.js';

const MAX_SEEN_TXN_IDS = 1000;

function safeTokenCompare(presented: string | undefined, expected: string): boolean {
  if (presented === undefined) return false;
  const a = Buffer.from(presented);
  const b = Buffer.from(expected);
  if (a.length !== b.length) {
    // Compare against a same-length dummy so length is not a timing oracle.
    timingSafeEqual(a, Buffer.alloc(a.length));
    return false;
  }
  return timingSafeEqual(a, b);
}

export interface TransactionHandlerOptions {
  hsToken: string;
  onEvent: EventHandler;
  /** Called for handler errors; events are at-most-once, errors must not 500. */
  onError?: (error: unknown, txnId: string) => void;
}

/**
 * Framework-agnostic handler for the Application Service transactions API
 * (PUT /_matrix/app/v1/transactions/{txnId}). Host apps (Fastify/Nest) wrap
 * this in a route.
 *
 * Spec requirements covered: hs_token verification (Authorization: Bearer,
 * with legacy ?access_token fallback), txnId idempotency, always-200 on
 * accepted transactions (homeserver retries on any other status).
 *
 * KNOWN LIMITATION: the txnId dedupe ring is in-process memory only. After a
 * restart the homeserver may redeliver pending transactions — event handlers
 * must be idempotent (delivery is at-least-once across process lifetimes).
 */
export class TransactionHandler {
  private readonly seen: string[] = [];
  private readonly seenSet = new Set<string>();

  constructor(private readonly options: TransactionHandlerOptions) {}

  authorized(
    authorizationHeader: string | undefined,
    accessTokenParam: string | undefined,
  ): boolean {
    const bearer = authorizationHeader?.startsWith('Bearer ')
      ? authorizationHeader.slice('Bearer '.length)
      : undefined;
    const presented = bearer ?? accessTokenParam;
    return safeTokenCompare(presented, this.options.hsToken);
  }

  async handle(
    txnId: string,
    body: unknown,
    auth: { authorizationHeader?: string; accessTokenParam?: string },
  ): Promise<HandlerResult> {
    if (!this.authorized(auth.authorizationHeader, auth.accessTokenParam)) {
      return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'bad hs_token' } };
    }
    if (this.seenSet.has(txnId)) {
      return { status: 200, body: {} };
    }
    this.markSeen(txnId);
    const txn = (body ?? {}) as Partial<Transaction>;
    for (const event of txn.events ?? []) {
      try {
        await this.options.onEvent(event);
      } catch (error) {
        // A failing handler must not fail the transaction: the homeserver
        // would retry the whole batch forever.
        this.options.onError?.(error, txnId);
      }
    }
    return { status: 200, body: {} };
  }

  private markSeen(txnId: string): void {
    this.seen.push(txnId);
    this.seenSet.add(txnId);
    while (this.seen.length > MAX_SEEN_TXN_IDS) {
      const evicted = this.seen.shift();
      if (evicted !== undefined) this.seenSet.delete(evicted);
    }
  }
}
