/**
 * The per-caller daily token budget: a Durable Object plus the injectable
 * store the worker talks to.
 *
 * A Durable Object gives each caller identity one strongly consistent,
 * single-threaded instance with great distributed performance, so
 * debit-then-store is atomic and the budget holds everywhere at once.
 *
 * The debit is the request's declared max_tokens, taken before forwarding.
 * Simple and worst-case-safe: the caller can never be billed more output than
 * was debited. The honest trade is in the walkthrough's closing section.
 */
import type { DurableObjectNamespaceLike, Env } from "./env.ts";

/** Injectable seam: the worker depends on this, tests inject an in-memory fake. */
export interface BudgetStore {
  debit(
    callerId: string,
    tokens: number,
    limit: number,
  ): Promise<{ ok: boolean; remaining: number }>;
}

/** The slice of DurableObjectState the class uses; tests fake it with a Map. */
interface StorageLike {
  get(key: string): Promise<unknown>;
  put(key: string, value: unknown): Promise<void>;
}

interface StateLike {
  storage: StorageLike;
}

interface DebitRequest {
  tokens: number;
  limit: number;
  day: string; // UTC date, "YYYY-MM-DD"; a new day resets the window
}

/**
 * One instance per caller identity (idFromName). Classic fetch-based Durable
 * Object: no cloudflare:workers import, so node:test can instantiate it
 * directly with a fake storage.
 */
export class TokenBudget {
  storage: StorageLike;

  constructor(state: StateLike) {
    this.storage = state.storage;
  }

  async fetch(request: Request): Promise<Response> {
    const { tokens, limit, day } = (await request.json()) as DebitRequest;
    const window = ((await this.storage.get("window")) ?? {
      day,
      used: 0,
    }) as { day: string; used: number };
    const used = window.day === day ? window.used : 0;
    if (used + tokens > limit) {
      // Refused requests are not debited; the caller keeps what remains.
      return Response.json({ ok: false, remaining: Math.max(0, limit - used) });
    }
    await this.storage.put("window", { day, used: used + tokens });
    return Response.json({ ok: true, remaining: limit - used - tokens });
  }
}

/** Wrap the TOKEN_BUDGET binding, or null when it is not configured. */
export function budgetStoreFromEnv(env: Env): BudgetStore | null {
  const ns: DurableObjectNamespaceLike | undefined = env.TOKEN_BUDGET;
  if (!ns) return null;
  return {
    async debit(callerId, tokens, limit) {
      const stub = ns.get(ns.idFromName(callerId));
      const day = new Date().toISOString().slice(0, 10);
      const res = await stub.fetch("https://budget/debit", {
        method: "POST",
        body: JSON.stringify({ tokens, limit, day }),
      });
      return (await res.json()) as { ok: boolean; remaining: number };
    },
  };
}
