import type { Language } from "@/i18n/resolver";
import type { Logger } from "@/observability/logger/types";
import { ApiHttpError, ApiTimeoutError, ApiNetworkError } from "./errors.js";

export interface ApiClientRetryOptions {
  maxRetries?: number;
  timeoutFactor?: number;
  statusCodes?: number[];
}

export interface ApiClientOptions {
  baseUrl: string;
  locale: Language;
  traceId?: string;
  fetchImpl?: typeof fetch;
  defaultTimeoutMs?: number;
  retry?: ApiClientRetryOptions;
  logger?: Logger;
}

const DEFAULT_RETRY_STATUS_CODES = [408, 429, 500, 502, 503, 504];

export class ApiClient {
  private readonly baseUrl: string;
  readonly locale: Language;
  private readonly traceId: string | undefined;
  private readonly fetchFn: typeof fetch;
  private readonly timeoutMs: number;
  private readonly maxRetries: number;
  private readonly timeoutFactor: number;
  private readonly retryStatusCodes: Set<number>;
  private readonly logger: Logger | undefined;

  constructor(options: ApiClientOptions) {
    this.baseUrl = options.baseUrl.replace(/\/$/, "");
    this.locale = options.locale;
    this.traceId = options.traceId;
    this.fetchFn = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
    this.timeoutMs = options.defaultTimeoutMs ?? 5000;
    this.maxRetries = options.retry?.maxRetries ?? 3;
    this.timeoutFactor = options.retry?.timeoutFactor ?? 2;
    this.retryStatusCodes = new Set(
      options.retry?.statusCodes ?? DEFAULT_RETRY_STATUS_CODES,
    );
    this.logger = options.logger;
  }

  async get<T>(
    path: string,
    query?: Record<string, string | number | boolean>,
  ): Promise<T> {
    const url = this.buildUrl(path, query);
    return this.executeWithRetry<T>(url, { method: "GET" });
  }

  async post<T>(path: string, body: unknown): Promise<T> {
    const url = this.buildUrl(path);
    return this.executeWithRetry<T>(url, {
      method: "POST",
      body: JSON.stringify(body),
      headers: { "Content-Type": "application/json" },
    });
  }

  private buildUrl(
    path: string,
    query?: Record<string, string | number | boolean>,
  ): string {
    const base = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
    if (!query || Object.keys(query).length === 0) return base;

    const params = new URLSearchParams();
    for (const [key, value] of Object.entries(query)) {
      params.set(key, String(value));
    }
    return `${base}?${params.toString()}`;
  }

  private async executeWithRetry<T>(
    url: string,
    init: RequestInit,
  ): Promise<T> {
    const headers = new Headers(init.headers ?? {});
    headers.set("Accept-Language", this.locale);
    if (this.traceId) {
      headers.set("X-Trace-Id", this.traceId);
    }

    let lastError: Error | undefined;

    for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
      if (attempt > 0) {
        const delay = this.calculateDelay(attempt);
        if (delay > 0) {
          await new Promise((resolve) => setTimeout(resolve, delay));
        }
      }

      try {
        const response = await this.fetchWithTimeout(url, {
          ...init,
          headers,
        });

        if (response.ok) {
          return (await response.json()) as T;
        }

        const status = response.status;
        let body: unknown;
        try {
          body = await response.json();
        } catch {
          // body may not be JSON
        }

        const httpError = new ApiHttpError(
          `HTTP ${status} from ${url}`,
          status,
          body,
        );

        if (this.retryStatusCodes.has(status) && attempt < this.maxRetries) {
          lastError = httpError;
          this.logger?.warn("Retrying request", {
            url,
            status,
            attempt: attempt + 1,
          });
          continue;
        }

        throw httpError;
      } catch (err) {
        if (err instanceof ApiHttpError || err instanceof ApiTimeoutError) {
          if (err instanceof ApiTimeoutError && attempt < this.maxRetries) {
            lastError = err;
            continue;
          }
          throw err;
        }
        if (err instanceof Error) {
          if (attempt < this.maxRetries) {
            lastError = new ApiNetworkError(err);
            continue;
          }
          throw new ApiNetworkError(err);
        }
        throw err;
      }
    }

    throw lastError ?? new Error("Unexpected retry exhaustion");
  }

  private async fetchWithTimeout(
    url: string,
    init: RequestInit,
  ): Promise<Response> {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);

    try {
      return await this.fetchFn(url, {
        ...init,
        signal: controller.signal,
      });
    } catch (err) {
      if (err instanceof Error && err.name === "AbortError") {
        throw new ApiTimeoutError(this.timeoutMs);
      }
      throw err;
    } finally {
      clearTimeout(timeoutId);
    }
  }

  private calculateDelay(attempt: number): number {
    if (this.timeoutFactor === 0) return 0;
    return Math.min(1000 * this.timeoutFactor ** (attempt - 1), 30000);
  }
}
