import type { ApiError } from '@alqove/types';

export interface ClientConfig {
  baseUrl: string;
  getToken?: () => string | null;
}

export class AlqoveClient {
  private config: ClientConfig;

  constructor(config: ClientConfig) {
    this.config = config;
  }

  /**
   * Base URL the client was constructed with. Exposed so endpoint modules can
   * synthesize absolute URLs for non-JSON downloads (e.g., CSV exports) that
   * need to be opened separately from the JSON request pipeline.
   */
  get baseUrl(): string {
    return this.config.baseUrl;
  }

  /**
   * Stream a binary response as a Blob, threading the bearer token through
   * the `Authorization` header. Used for endpoints whose response is not
   * JSON (e.g., the seller statements CSV export). A plain `<a href>` link
   * cannot carry the bearer token, so the UI fetches the blob and programmatically
   * triggers the download via an object URL.
   */
  async getBlob(path: string): Promise<Blob> {
    const url = new URL(`${this.config.baseUrl}${path}`);

    const headers: Record<string, string> = {};
    const token = this.config.getToken?.();
    if (token) {
      headers['Authorization'] = `Bearer ${token}`;
    }

    const response = await fetch(url.toString(), {
      method: 'GET',
      headers,
    });

    if (!response.ok) {
      // Best-effort JSON error parse; fall back to status text.
      try {
        const error: ApiError = await response.json();
        throw error;
      } catch {
        throw new Error(`Download failed (${response.status} ${response.statusText})`);
      }
    }

    return response.blob();
  }

  /**
   * Build the request headers for a call.
   *
   * Per-call `overrides` (Task 0) let public/idempotent endpoints control the
   * auth + idempotency behavior that the default buyer flow handles implicitly:
   *  - `authToken` attaches `Authorization: Bearer <authToken>` for this call
   *    only, overriding the constructed `getToken`. Used by the optional-login
   *    check-in lane, which must work anonymously and never fall back to the
   *    buyer `localStorage` token.
   *  - a caller-supplied `headers['Idempotency-Key']` SUPPRESSES the
   *    auto-generated random key, so a stable key can be sent in both the body
   *    and the header (the API enforces equality). Any other caller headers are
   *    merged last.
   */
  private getHeaders(
    method: string,
    overrides?: { headers?: Record<string, string>; authToken?: string },
  ): Record<string, string> {
    const headers: Record<string, string> = {
      'Content-Type': 'application/json',
      Accept: 'application/json',
    };

    const token = overrides?.authToken ?? this.config.getToken?.();
    if (token) {
      headers['Authorization'] = `Bearer ${token}`;
    }

    const callerSuppliedIdempotencyKey =
      overrides?.headers?.['Idempotency-Key'] !== undefined;
    if (method !== 'GET' && method !== 'DELETE' && !callerSuppliedIdempotencyKey) {
      headers['Idempotency-Key'] = crypto.randomUUID();
    }

    // Caller headers win — they override Content-Type and any auto key.
    if (overrides?.headers) {
      Object.assign(headers, overrides.headers);
    }

    return headers;
  }

  async request<T>(
    method: string,
    path: string,
    options?: {
      body?: unknown;
      params?: Record<string, string | string[]>;
      headers?: Record<string, string>;
      authToken?: string;
    }
  ): Promise<T> {
    const url = new URL(`${this.config.baseUrl}${path}`);

    if (options?.params) {
      Object.entries(options.params).forEach(([key, value]) => {
        if (Array.isArray(value)) {
          // PHP only parses repeated params into an array when the key carries
          // `[]`; plain `key=a&key=b` arrives as the scalar "b" and fails the
          // API's `array` validation rules.
          value.forEach((v) => url.searchParams.append(`${key}[]`, v));
        } else {
          url.searchParams.set(key, value);
        }
      });
    }

    const response = await fetch(url.toString(), {
      method,
      headers: this.getHeaders(method, {
        headers: options?.headers,
        authToken: options?.authToken,
      }),
      body: options?.body ? JSON.stringify(options.body) : undefined,
    });

    if (!response.ok) {
      const error: ApiError = await response.json();
      throw error;
    }

    // 204 No Content (e.g. DELETE) has no body — response.json() would throw.
    if (response.status === 204) {
      return undefined as T;
    }

    return response.json();
  }

  get<T>(path: string, params?: Record<string, string | string[]>) {
    return this.request<T>('GET', path, { params });
  }

  post<T>(
    path: string,
    body?: unknown,
    options?: { headers?: Record<string, string>; authToken?: string },
  ) {
    return this.request<T>('POST', path, {
      body,
      headers: options?.headers,
      authToken: options?.authToken,
    });
  }

  put<T>(path: string, body?: unknown) {
    return this.request<T>('PUT', path, { body });
  }

  patch<T>(path: string, body?: unknown) {
    return this.request<T>('PATCH', path, { body });
  }

  delete<T>(path: string) {
    return this.request<T>('DELETE', path);
  }

  async postFormData<T>(path: string, formData: FormData): Promise<T> {
    const url = new URL(`${this.config.baseUrl}${path}`);

    const headers: Record<string, string> = {
      Accept: 'application/json',
      'Idempotency-Key': crypto.randomUUID(),
    };

    const token = this.config.getToken?.();
    if (token) {
      headers['Authorization'] = `Bearer ${token}`;
    }

    // Do NOT set Content-Type — browser sets it with multipart boundary
    const response = await fetch(url.toString(), {
      method: 'POST',
      headers,
      body: formData,
    });

    if (!response.ok) {
      const error: ApiError = await response.json();
      throw error;
    }

    return response.json();
  }
}
