/** TypeScript-compatible, dependency-free CleanedWeb reference client. */

type JsonObject = Record<string, any>;
type FetchLike = typeof fetch;

interface ClientOptions {
  apiKey: string;
  fetchImpl?: FetchLike;
  baseUrl?: string;
}

interface RequestOptions {
  method?: string;
  headers?: Record<string, string>;
  authenticated?: boolean;
  body?: JsonObject;
  includeHeaders?: boolean;
}

interface SearchOptions {
  idempotencyKey: string;
  requireComplete?: boolean;
}

export class CleanedWebError extends Error {
  status?: number;
  code?: string;
  override cause?: unknown;

  constructor(message: string, details: { status?: number; code?: string } = {}) {
    super(message);
    this.name = "CleanedWebError";
    this.status = details.status;
    this.code = details.code;
  }
}

export class CleanedWebClient {
  private readonly apiKey: string;
  private readonly fetch: FetchLike;
  private readonly baseUrl: string;

  constructor({ apiKey, fetchImpl = fetch, baseUrl = "https://app.cleanedweb.com" }: ClientOptions) {
    if (!apiKey?.startsWith("cw_")) throw new TypeError("A CleanedWeb API key is required");
    this.apiKey = apiKey;
    this.fetch = fetchImpl;
    this.baseUrl = baseUrl.replace(/\/$/, "");
  }

  async capabilities(): Promise<JsonObject> {
    return this.request("/v1/capabilities", { authenticated: false });
  }

  async markets(): Promise<JsonObject> {
    return this.request("/v1/markets");
  }

  async locations(market: string, query: string, limit = 8): Promise<JsonObject> {
    const params = new URLSearchParams({ market, q: query, limit: String(limit) });
    return this.request(`/v1/properties/locations?${params}`);
  }

  async getProperty(propertyEntityId: string): Promise<JsonObject> {
    return this.request(`/v1/properties/${encodeURIComponent(propertyEntityId)}`);
  }

  async propertyChanges(propertyEntityId: string, limit = 50): Promise<JsonObject> {
    if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
      throw new TypeError("limit must be between 1 and 200");
    }
    return this.request(
      `/v1/properties/${encodeURIComponent(propertyEntityId)}/changes?limit=${limit}`,
    );
  }

  async search(query: JsonObject, { idempotencyKey, requireComplete = true }: SearchOptions) {
    const capabilities = await this.capabilities();
    if (capabilities.contract_version !== "v1") {
      throw new CleanedWebError("Unsupported capabilities contract");
    }
    if (capabilities.machine_access?.enabled !== true) {
      throw new CleanedWebError("Machine access is not enabled by runtime capabilities");
    }
    if (!Object.keys(capabilities.unit_costs ?? {}).length) {
      throw new CleanedWebError("Runtime Unit costs are not published");
    }
    if (!Number.isInteger(query.limit) || query.limit < 1) {
      throw new TypeError("query.limit must be a positive integer");
    }
    if (typeof query.max_units !== "number" || query.max_units < 0) {
      throw new TypeError("query.max_units must be a non-negative number");
    }
    if (!idempotencyKey?.trim()) throw new TypeError("idempotencyKey is required");
    if (query.response_schema !== "property-profile-v2") {
      throw new TypeError("query.response_schema must be property-profile-v2");
    }

    const { payload, headers } = await this.request("/v1/properties/search", {
      method: "POST",
      headers: { "Idempotency-Key": idempotencyKey },
      body: query,
      includeHeaders: true,
    });
    this.acceptSearch(payload, { requireComplete });
    return {
      response: payload,
      requestId: headers.get("x-cleanedweb-request-id"),
      units: headers.get("x-cleanedweb-units"),
      balance: headers.get("x-cleanedweb-unit-balance"),
    };
  }

  private acceptSearch(payload: JsonObject, { requireComplete }: { requireComplete: boolean }) {
    if (!Array.isArray(payload.results) || payload.returned !== payload.results.length) {
      throw new CleanedWebError("Search response count mismatch");
    }
    const identities = new Set();
    for (const record of payload.results) {
      const identity = record.property_entity_id;
      if (record.schema_version !== "property-profile-v2") {
        throw new CleanedWebError("Unsupported property schema");
      }
      if (typeof identity !== "string" || !identity || identities.has(identity)) {
        throw new CleanedWebError("Missing or duplicate canonical property identity");
      }
      identities.add(identity);
    }
    if (requireComplete && payload.truncated !== false) {
      throw new CleanedWebError("Complete search required but response is truncated");
    }
    if (typeof payload.snapshot?.dataUpdatedAt !== "string") {
      throw new CleanedWebError("Search snapshot timestamp is unavailable");
    }
  }

  private async request(path: string, options: RequestOptions = {}): Promise<any> {
    const headers = new Headers({ Accept: "application/json", ...(options.headers ?? {}) });
    if (options.authenticated !== false) headers.set("Authorization", `Bearer ${this.apiKey}`);
    if (options.body !== undefined) headers.set("Content-Type", "application/json");
    let response;
    try {
      response = await this.fetch(this.baseUrl + path, {
        method: options.method ?? "GET",
        headers,
        body: options.body === undefined ? undefined : JSON.stringify(options.body),
      });
    } catch (error: unknown) {
      const reason = error instanceof Error ? error.message : String(error);
      const wrapped = new CleanedWebError(`CleanedWeb request failed: ${reason}`);
      wrapped.cause = error;
      throw wrapped;
    }
    const payload = await response.json().catch(() => ({}));
    if (!response.ok) {
      throw new CleanedWebError(payload.error?.message ?? `CleanedWeb returned HTTP ${response.status}`, {
        status: response.status,
        code: payload.error?.code,
      });
    }
    return options.includeHeaders ? { payload, headers: response.headers } : payload;
  }
}
