/**
 * BookAfterwork API — official TypeScript client (v1.2.0)
 * Zero dependencies. Node 18+, Deno, Bun, Cloudflare Workers.
 *
 * SECURITY: partner keys are secrets. Use this client on your server only —
 * never ship a `bak_live_` key in a browser bundle.
 *
 *   import { BookAfterwork, verifyWebhook } from "./bookafterwork";
 *   const bak = new BookAfterwork({ apiKey: process.env.BAK_API_KEY! });
 *   const req = await bak.createRequest({ message: "Afterwork 35 personnes à Lyon le 12 mars" });
 *   const reco = await bak.recommend({ request_id: req.request_id, limit: 3 });
 *
 * Docs: https://bookafterwork.com/developpeurs/documentation · Spec: https://bookafterwork.com/openapi.json
 */

export const VERSION = "1.2.0";

// ───────────────────────── Types (mirror openapi.json) ─────────────────────────

export type Locale = "fr" | "fr-ca" | "en" | "de" | "es" | "it" | "nl" | "pt";
export type AvailabilityStatus = "available" | "likely_available" | "to_confirm" | "unlikely" | "unavailable";
export type BookingStatus = "pending" | "accepted" | "confirmed" | "completed" | "cancelled";
export type PaymentStatus = "payment_pending" | "paid" | "failed" | "refunded";
export type WebhookEventType =
  | "request.dispatched" | "request.accepted" | "request.declined"
  | "quote.created" | "quote.updated" | "quote.accepted"
  | "booking.created" | "booking.updated" | "booking.cancelled"
  | "payment.succeeded" | "payment.failed";
export type ErrorCode =
  | "UNAUTHORIZED" | "INVALID_API_KEY" | "KEY_REVOKED" | "KEY_EXPIRED" | "INSUFFICIENT_SCOPE" | "FORBIDDEN"
  | "RATE_LIMITED" | "QUOTA_EXCEEDED" | "INVALID_PAYLOAD" | "IDEMPOTENCY_KEY_REQUIRED" | "IDEMPOTENCY_KEY_REUSED"
  | "REQUEST_NOT_FOUND" | "VENUE_NOT_FOUND" | "VENUE_NOT_AVAILABLE" | "MESSAGE_TOO_SHORT" | "MISSING_DATE"
  | "MISSING_GUESTS" | "MISSING_CONTACT" | "NO_VENUES" | "BOOKING_NOT_CANCELLABLE" | "BOOKING_LOCKED"
  | "NOT_IMPLEMENTED" | "INTERNAL_ERROR"
  // SDK-side codes
  | "NETWORK_ERROR" | "TIMEOUT" | "ABORTED" | "INVALID_RESPONSE" | "HTTP_ERROR";

export interface Brief {
  event_type: string | null; city: string | null; area: string | null; country: string | null;
  date: string | null; end_date: string | null; start_time: string | null; end_time: string | null;
  guests: number | null; budget_max: number | null; budget_currency: string | null;
  venue_type: string | null; food: boolean | null; drinks: boolean | null;
  accommodation: boolean | null; privatization: boolean | null; ambiance: string | null;
  equipment: string[]; activities: string[]; services: string[]; constraints: string[];
}
export interface ParseResponse {
  brief: Brief; confidence_score: number; missing_fields: string[]; ambiguous_fields: string[];
  extraction_engine: "local" | "ai";
}
export interface MatchReason { code: string; weight?: number | null }
export interface VenueMatch {
  venue_id: string; name: string; city?: string | null; district?: string | null; country?: string | null;
  capacity?: number | null; match_score: number; estimated_price?: number | null; price_per_person?: number | null;
  availability_status: AvailabilityStatus; reasons?: string[]; match_reasons?: MatchReason[];
  missing_requirements?: Array<string | MatchReason>; response_rate?: number | null; acceptance_rate?: number | null;
  average_response_time_hours?: number | null; score_breakdown?: Record<string, number>; photo?: string | null;
}
export interface ProviderMatch {
  provider_id: string; business_name: string; category: string; city?: string | null; country?: string | null;
  match_score: number; base_price_from?: number | null; price_unit?: string | null; currency?: string | null;
  rating?: number | null; review_count?: number; reasons?: string[]; missing_requirements?: string[];
  slug?: string | null; photo?: string | null;
}
export interface SearchVenuesResponse { results: VenueMatch[]; candidate_count: number }
export interface RecommendResponse { run_id: string; weights?: Record<string, number>; candidate_count: number; results: VenueMatch[] }
export interface RecommendProvidersResponse { candidate_count: number; results: ProviderMatch[] }
export interface CreateRequestResponse { request_id: string; status?: string; brief: Partial<Brief>; confidence_score?: number; missing_fields?: string[] }
/** A binding offer issued by a venue. Amounts in major units, VAT included. */
export type QuoteStatus = "draft" | "sent" | "accepted" | "declined" | "expired";
export interface Quote {
  quote_id: string; quote_number?: string | null; venue_id: string; booking_id?: string | null; status: QuoteStatus;
  /** Total incl. VAT (TTC). */ amount?: number | null; /** Excl. VAT (HT). */ subtotal?: number | null; vat_amount?: number | null;
  deposit_amount?: number | null; price_per_person?: number | null; currency?: string | null; valid_until?: string | null; created_at?: string;
}
export interface Booking {
  booking_id: string; venue_id: string; quote_id?: string; status: BookingStatus; payment_status?: PaymentStatus;
  event_date?: string | null; event_time?: string | null; guests?: number | null; notes?: string | null;
  cancelled_at?: string | null; amount?: number | null; deposit_amount?: number | null;
}
/** A checkout link, NOT a completed payment: wait for the `payment.succeeded` webhook. */
export interface Payment { booking_id: string; payment_status: PaymentStatus; amount_due: number; checkout_url: string; expires_in: number }
export interface WebhookEvent<T = Record<string, unknown>> { id: string; event: WebhookEventType; entity_id: string; created_at: string; data: T }
export interface Webhook { id: string; url: string; events: WebhookEventType[]; secret?: string; created_at?: string }

// ───────────────────────── Errors ─────────────────────────

export class BookAfterworkError extends Error {
  constructor(
    public readonly status: number,
    public readonly code: ErrorCode | string,
    message: string,
    public readonly retryAfter: number | null = null,
    public readonly requiredScope: string | null = null,
    options?: { cause?: unknown },
  ) {
    super(message, options);
    this.name = "BookAfterworkError";
  }
  /** True when the outcome on the server is unknown (network loss / timeout on a write). Retry with the SAME Idempotency-Key. */
  get uncertain(): boolean {
    return this.status === 0;
  }
}

// ───────────────────────── Client ─────────────────────────

export interface ClientOptions {
  apiKey: string;
  baseUrl?: string;
  /** Retries on 429 / 5xx / network errors, only for GET or writes carrying an Idempotency-Key. Default 2, max 5. */
  maxRetries?: number;
  /** Per-attempt timeout in ms. Default 30 000. */
  timeoutMs?: number;
  /** Upper bound for a single backoff wait, ms. Default 30 000. */
  maxDelayMs?: number;
  fetch?: typeof fetch;
  /** Allow running in a browser (only for bak_test_ keys behind your own proxy). Default false. */
  dangerouslyAllowBrowser?: boolean;
}
export interface RequestOptions { idempotencyKey?: string; signal?: AbortSignal }

const DEFAULT_BASE = "https://bookafterwork.com/api/public/v1";
const ALLOWED_HOSTS = new Set(["bookafterwork.com", "www.bookafterwork.com", "localhost", "127.0.0.1"]);

export const newIdempotencyKey = (): string =>
  globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;

const seg = (value: string, name = "id"): string => {
  if (typeof value !== "string" || !value.trim()) throw new TypeError(`${name} must be a non-empty string`);
  return encodeURIComponent(value);
};
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const assertLimit = (limit: number | undefined) => {
  if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 25)) throw new RangeError("limit must be an integer between 1 and 25");
};

export function parseRetryAfter(value: string | null, now = Date.now()): number | null {
  if (!value) return null;
  const v = value.trim();
  if (/^\d+$/.test(v)) return Number(v);
  const at = Date.parse(v);
  return Number.isNaN(at) ? null : Math.max(0, Math.ceil((at - now) / 1000));
}

export class BookAfterwork {
  readonly baseUrl: string;
  private readonly apiKey: string;
  private readonly maxRetries: number;
  private readonly timeoutMs: number;
  private readonly maxDelayMs: number;
  private readonly fetchImpl: typeof fetch;

  constructor(opts: ClientOptions) {
    if (!opts?.apiKey || !/^bak_(live|test)_/.test(opts.apiKey)) throw new TypeError("apiKey must start with bak_live_ or bak_test_");
    if (typeof window !== "undefined" && typeof document !== "undefined" && !opts.dangerouslyAllowBrowser) {
      throw new Error("BookAfterwork: do not use a secret API key in the browser. Call the API from your server.");
    }
    const url = new URL(opts.baseUrl ?? DEFAULT_BASE);
    const local = url.hostname === "localhost" || url.hostname === "127.0.0.1";
    if (url.protocol !== "https:" && !local) throw new TypeError("baseUrl must use https");
    if (!ALLOWED_HOSTS.has(url.hostname)) throw new TypeError(`baseUrl host ${url.hostname} is not an approved BookAfterwork host`);
    this.baseUrl = url.toString().replace(/\/$/, "");
    this.apiKey = opts.apiKey;
    this.maxRetries = Math.min(Math.max(opts.maxRetries ?? 2, 0), 5);
    this.timeoutMs = opts.timeoutMs ?? 30_000;
    this.maxDelayMs = opts.maxDelayMs ?? 30_000;
    this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
  }

  /** Never serialize the key. */
  toJSON() {
    return { baseUrl: this.baseUrl, apiKey: `${this.apiKey.slice(0, 9)}…` };
  }

  private delay(attempt: number, retryAfter: number | null): number {
    if (retryAfter !== null) return Math.min(retryAfter * 1000, this.maxDelayMs);
    const base = Math.min(500 * 2 ** attempt, this.maxDelayMs);
    return base / 2 + Math.random() * (base / 2); // equal jitter
  }

  async request<T>(method: "GET" | "POST" | "PATCH" | "DELETE", path: string, body?: unknown, opts: RequestOptions = {}): Promise<T> {
    const headers: Record<string, string> = {
      Authorization: `Bearer ${this.apiKey}`,
      Accept: "application/json",
      "User-Agent": `bookafterwork-ts/${VERSION}`,
    };
    if (body !== undefined) headers["Content-Type"] = "application/json";
    if (opts.idempotencyKey) headers["Idempotency-Key"] = opts.idempotencyKey;
    const payload = body === undefined ? undefined : JSON.stringify(body); // same bytes on every attempt
    const retrySafe = method === "GET" || method === "DELETE" || Boolean(opts.idempotencyKey);

    for (let attempt = 0; ; attempt++) {
      if (opts.signal?.aborted) throw new BookAfterworkError(0, "ABORTED", "Request aborted", null, null, { cause: opts.signal.reason });
      const ctrl = new AbortController();
      const onAbort = () => ctrl.abort(opts.signal?.reason);
      opts.signal?.addEventListener("abort", onAbort, { once: true });
      const timer = setTimeout(() => ctrl.abort(new Error("timeout")), this.timeoutMs);
      let res: Response;
      try {
        // redirect: "manual" → the Authorization header is never forwarded to another origin.
        res = await this.fetchImpl(`${this.baseUrl}${path}`, { method, headers, body: payload, signal: ctrl.signal, redirect: "manual" });
      } catch (cause) {
        const aborted = opts.signal?.aborted;
        const err = new BookAfterworkError(0, aborted ? "ABORTED" : ctrl.signal.aborted ? "TIMEOUT" : "NETWORK_ERROR",
          aborted ? "Request aborted" : ctrl.signal.aborted ? `No response within ${this.timeoutMs} ms` : "Network error", null, null, { cause });
        if (!aborted && retrySafe && attempt < this.maxRetries) { await sleep(this.delay(attempt, null)); continue; }
        throw err;
      } finally {
        clearTimeout(timer);
        opts.signal?.removeEventListener("abort", onAbort);
      }

      if (res.status >= 300 && res.status < 400) {
        throw new BookAfterworkError(res.status, "HTTP_ERROR", "Unexpected redirect refused (credentials are never forwarded)");
      }
      const text = await res.text();
      let data: unknown = null;
      if (text) {
        try { data = JSON.parse(text); } catch (cause) {
          if (res.ok) throw new BookAfterworkError(res.status, "INVALID_RESPONSE", "Response is not valid JSON", null, null, { cause });
        }
      }
      if (res.ok) {
        if (res.status !== 204 && (data === null || typeof data !== "object" || Array.isArray(data))) {
          throw new BookAfterworkError(res.status, "INVALID_RESPONSE", "Expected a JSON object");
        }
        return (data ?? {}) as T;
      }
      const retryAfter = parseRetryAfter(res.headers.get("Retry-After"));
      if ((res.status === 429 || res.status >= 500) && retrySafe && attempt < this.maxRetries) {
        await sleep(this.delay(attempt, retryAfter));
        continue;
      }
      const e = (data && typeof data === "object" ? (data as { error?: { code?: string; message?: string; required_scope?: string } }).error : undefined) ?? {};
      throw new BookAfterworkError(res.status, e.code ?? "HTTP_ERROR", e.message ?? (res.statusText || `HTTP ${res.status}`), retryAfter, e.required_scope ?? null);
    }
  }

  // AI
  parseRequest(body: { message: string; locale?: Locale }, o?: RequestOptions) {
    if (!body?.message || body.message.trim().length < 8) throw new TypeError("message must be at least 8 characters");
    return this.request<ParseResponse>("POST", "/ai/parse-request", body, o);
  }
  recommend(body: { request_id?: string; brief?: Partial<Brief>; limit?: number }, o?: RequestOptions) {
    assertLimit(body.limit);
    if (!body.request_id && !body.brief) throw new TypeError("request_id or brief is required");
    return this.request<RecommendResponse>("POST", "/ai/recommend", body, o);
  }
  recommendProviders(body: { request_id?: string; brief?: Partial<Brief>; limit?: number }, o?: RequestOptions) {
    assertLimit(body.limit);
    if (!body.request_id && !body.brief) throw new TypeError("request_id or brief is required");
    return this.request<RecommendProvidersResponse>("POST", "/ai/recommend-providers", body, o);
  }
  compareQuotes(requestId: string, o?: RequestOptions) {
    seg(requestId, "requestId");
    return this.request<Record<string, unknown>>("POST", "/ai/compare-quotes", { request_id: requestId }, o);
  }

  // Venues
  searchVenues(body: { city?: string; guests?: number; date?: string; budget_max?: number; venue_type?: string; ambiance?: string; requirements?: string[]; limit?: number }, o?: RequestOptions) {
    assertLimit(body.limit);
    if (body.guests !== undefined && (!Number.isInteger(body.guests) || body.guests < 1)) throw new RangeError("guests must be a positive integer");
    if (body.date !== undefined && !ISO_DATE.test(body.date)) throw new TypeError("date must be YYYY-MM-DD");
    return this.request<SearchVenuesResponse>("POST", "/venues/search", body, o);
  }
  getAvailability(venueId: string, from?: string, to?: string, o?: RequestOptions) {
    for (const d of [from, to]) if (d !== undefined && !ISO_DATE.test(d)) throw new TypeError("from/to must be YYYY-MM-DD");
    const q = new URLSearchParams();
    if (from) q.set("from", from);
    if (to) q.set("to", to);
    const qs = q.toString();
    return this.request<Record<string, unknown>>("GET", `/venues/${seg(venueId, "venueId")}/availability${qs ? `?${qs}` : ""}`, undefined, o);
  }
  publishAvailability(venueId: string, body: { days?: Array<{ date: string; status: "available" | "unavailable" }>; slots?: Array<Record<string, unknown>> }, o: RequestOptions = {}) {
    return this.request<Record<string, unknown>>("POST", `/venues/${seg(venueId, "venueId")}/availability`, body, { ...o, idempotencyKey: o.idempotencyKey ?? newIdempotencyKey() });
  }

  // Requests
  createRequest(body: { message?: string; brief?: Partial<Brief>; locale?: Locale; company_name?: string; contact_email?: string; contact_phone?: string; notes?: string }, o: RequestOptions = {}) {
    if (!body.message && !body.brief) throw new TypeError("message or brief is required");
    return this.request<CreateRequestResponse>("POST", "/requests", body, { ...o, idempotencyKey: o.idempotencyKey ?? newIdempotencyKey() });
  }
  getRequest(requestId: string, o?: RequestOptions) {
    return this.request<Record<string, unknown>>("GET", `/requests/${seg(requestId, "requestId")}`, undefined, o);
  }
  dispatch(requestId: string, venueIds: string[], o: RequestOptions = {}) {
    if (!Array.isArray(venueIds) || venueIds.length === 0 || venueIds.length > 10) throw new RangeError("venueIds must contain 1 to 10 ids");
    return this.request<Record<string, unknown>>("POST", `/requests/${seg(requestId, "requestId")}/dispatch`, { venue_ids: venueIds }, { ...o, idempotencyKey: o.idempotencyKey ?? newIdempotencyKey() });
  }
  dispatchProviders(requestId: string, body: { provider_ids: string[] } & Record<string, unknown>, o: RequestOptions = {}) {
    return this.request<Record<string, unknown>>("POST", `/requests/${seg(requestId, "requestId")}/dispatch-providers`, body, { ...o, idempotencyKey: o.idempotencyKey ?? newIdempotencyKey() });
  }
  listQuotes(requestId: string, o?: RequestOptions) {
    return this.request<{ quotes: Quote[] }>("GET", `/requests/${seg(requestId, "requestId")}/quotes`, undefined, o);
  }

  // Bookings
  createBooking(quoteId: string, o: RequestOptions = {}) {
    seg(quoteId, "quoteId");
    return this.request<Booking>("POST", "/bookings", { quote_id: quoteId }, { ...o, idempotencyKey: o.idempotencyKey ?? newIdempotencyKey() });
  }
  getBooking(bookingId: string, o?: RequestOptions) {
    return this.request<Booking>("GET", `/bookings/${seg(bookingId, "bookingId")}`, undefined, o);
  }
  updateBooking(bookingId: string, body: { guests?: number; event_time?: string; notes?: string }, o: RequestOptions = {}) {
    if (body.event_time !== undefined && !/^([01]\d|2[0-3]):[0-5]\d$/.test(body.event_time)) throw new TypeError("event_time must be HH:MM");
    return this.request<Booking>("PATCH", `/bookings/${seg(bookingId, "bookingId")}`, body, { ...o, idempotencyKey: o.idempotencyKey ?? newIdempotencyKey() });
  }
  cancelBooking(bookingId: string, reason: string, o: RequestOptions = {}) {
    if (!reason || reason.trim().length < 3) throw new TypeError("reason must be at least 3 characters");
    return this.request<Booking & { refund?: string }>("POST", `/bookings/${seg(bookingId, "bookingId")}/cancel`, { reason }, { ...o, idempotencyKey: o.idempotencyKey ?? newIdempotencyKey() });
  }
  /**
   * Returns a hosted checkout URL. This is NOT a payment: the booking is paid only
   * after the `payment.succeeded` webhook. Reuse the same idempotencyKey to avoid duplicate links.
   */
  createPayment(bookingId: string, o: RequestOptions = {}) {
    return this.request<Payment>("POST", `/bookings/${seg(bookingId, "bookingId")}/payment`, {}, { ...o, idempotencyKey: o.idempotencyKey ?? newIdempotencyKey() });
  }

  // Webhooks
  listWebhooks(o?: RequestOptions) { return this.request<{ webhooks: Webhook[] }>("GET", "/webhooks", undefined, o); }
  createWebhook(url: string, events: WebhookEventType[], o: RequestOptions = {}) {
    if (!/^https:\/\//.test(url)) throw new TypeError("url must be https");
    return this.request<Webhook>("POST", "/webhooks", { url, events }, { ...o, idempotencyKey: o.idempotencyKey ?? newIdempotencyKey() });
  }
  deleteWebhook(id: string, o?: RequestOptions) {
    return this.request<Record<string, unknown>>("DELETE", `/webhooks/${seg(id, "webhookId")}`, undefined, o);
  }
}

const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));

// ───────────────────────── Webhooks ─────────────────────────

/**
 * Verifies `X-BookAfterwork-Signature: t=<unix>,v1=<hex>[,v1=<hex>]` — HMAC-SHA256 of `<t>.<raw body>`.
 * Pass the RAW body (string or bytes, before JSON.parse). Rejects malformed headers and timestamps
 * outside ±toleranceSec. Deduplicate on `event.id` in your own storage to block replays.
 */
export async function verifyWebhook(rawBody: string | Uint8Array, signatureHeader: string | null | undefined, secret: string, toleranceSec = 300, now = Date.now()): Promise<boolean> {
  if (!signatureHeader || !secret) return false;
  let t: number | null = null;
  const sigs: string[] = [];
  for (const part of signatureHeader.split(",")) {
    const i = part.indexOf("=");
    if (i <= 0) return false;
    const k = part.slice(0, i).trim();
    const v = part.slice(i + 1).trim();
    if (k === "t") { if (!/^\d{1,12}$/.test(v) || t !== null) return false; t = Number(v); }
    else if (k === "v1") { if (!/^[0-9a-f]{64}$/.test(v)) return false; sigs.push(v); }
  }
  if (t === null || sigs.length === 0 || Math.abs(now / 1000 - t) > toleranceSec) return false;
  const enc = new TextEncoder();
  const bodyBytes = typeof rawBody === "string" ? enc.encode(rawBody) : rawBody;
  const prefix = enc.encode(`${t}.`);
  const msg = new Uint8Array(prefix.length + bodyBytes.length);
  msg.set(prefix);
  msg.set(bodyBytes, prefix.length);
  const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
  const expected = Array.from(new Uint8Array(await crypto.subtle.sign("HMAC", key, msg)), (b) => b.toString(16).padStart(2, "0")).join("");
  let ok = false;
  for (const s of sigs) {
    let diff = 0;
    for (let i = 0; i < 64; i++) diff |= expected.charCodeAt(i) ^ s.charCodeAt(i);
    ok = ok || diff === 0;
  }
  return ok;
}
