import { URL } from "node:url";

export class ApiRequestError extends Error {
  status: number;
  details?: unknown;
  body?: unknown;

  constructor(status: number, message: string, details?: unknown, body?: unknown) {
    super(message);
    this.status = status;
    this.details = details;
    this.body = body;
  }
}

export class ApiConnectionError extends Error {
  url: string;
  method: string;
  causeMessage?: string;

  constructor(input: {
    apiBase: string;
    path: string;
    method: string;
    cause?: unknown;
  }) {
    const url = buildUrl(input.apiBase, input.path);
    const causeMessage = formatConnectionCause(input.cause);
    super(buildConnectionErrorMessage({ apiBase: input.apiBase, url, method: input.method, causeMessage }));
    this.url = url;
    this.method = input.method;
    this.causeMessage = causeMessage;
  }
}

interface RequestOptions {
  ignoreNotFound?: boolean;
}

interface RecoverAuthInput {
  path: string;
  method: string;
  error: ApiRequestError;
}

interface ApiClientOptions {
  apiBase: string;
  apiKey?: string;
  runId?: string;
  recoverAuth?: (input: RecoverAuthInput) => Promise<string | null>;
}

export class PaperclipApiClient {
  readonly apiBase: string;
  apiKey?: string;
  readonly runId?: string;
  readonly recoverAuth?: (input: RecoverAuthInput) => Promise<string | null>;

  constructor(opts: ApiClientOptions) {
    this.apiBase = opts.apiBase.replace(/\/+$/, "");
    this.apiKey = opts.apiKey?.trim() || undefined;
    this.runId = opts.runId?.trim() || undefined;
    this.recoverAuth = opts.recoverAuth;
  }

  get<T>(path: string, opts?: RequestOptions): Promise<T | null> {
    return this.request<T>(path, { method: "GET" }, opts);
  }

  post<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T | null> {
    return this.request<T>(path, {
      method: "POST",
      body: body === undefined ? undefined : JSON.stringify(body),
    }, opts);
  }

  patch<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T | null> {
    return this.request<T>(path, {
      method: "PATCH",
      body: body === undefined ? undefined : JSON.stringify(body),
    }, opts);
  }

  delete<T>(path: string, opts?: RequestOptions): Promise<T | null> {
    return this.request<T>(path, { method: "DELETE" }, opts);
  }

  setApiKey(apiKey: string | undefined) {
    this.apiKey = apiKey?.trim() || undefined;
  }

  private async request<T>(
    path: string,
    init: RequestInit,
    opts?: RequestOptions,
    hasRetriedAuth = false,
  ): Promise<T | null> {
    const url = buildUrl(this.apiBase, path);
    const method = String(init.method ?? "GET").toUpperCase();

    const headers: Record<string, string> = {
      accept: "application/json",
      ...toStringRecord(init.headers),
    };

    if (init.body !== undefined) {
      headers["content-type"] = headers["content-type"] ?? "application/json";
    }

    if (this.apiKey) {
      headers.authorization = `Bearer ${this.apiKey}`;
    }

    if (this.runId) {
      headers["x-paperclip-run-id"] = this.runId;
    }

    let response: Response;
    try {
      response = await fetch(url, {
        ...init,
        headers,
      });
    } catch (error) {
      throw new ApiConnectionError({
        apiBase: this.apiBase,
        path,
        method,
        cause: error,
      });
    }

    if (opts?.ignoreNotFound && response.status === 404) {
      return null;
    }

    if (!response.ok) {
      const apiError = await toApiError(response);
      if (!hasRetriedAuth && this.recoverAuth) {
        const recoveredToken = await this.recoverAuth({
          path,
          method,
          error: apiError,
        });
        if (recoveredToken) {
          this.setApiKey(recoveredToken);
          return this.request<T>(path, init, opts, true);
        }
      }
      throw apiError;
    }

    if (response.status === 204) {
      return null;
    }

    const text = await response.text();
    if (!text.trim()) {
      return null;
    }

    return safeParseJson(text) as T;
  }
}

function buildUrl(apiBase: string, path: string): string {
  const normalizedPath = path.startsWith("/") ? path : `/${path}`;
  const [pathname, query] = normalizedPath.split("?");
  const url = new URL(apiBase);
  url.pathname = `${url.pathname.replace(/\/+$/, "")}${pathname}`;
  if (query) url.search = query;
  return url.toString();
}

function safeParseJson(text: string): unknown {
  try {
    return JSON.parse(text);
  } catch {
    return text;
  }
}

async function toApiError(response: Response): Promise<ApiRequestError> {
  const text = await response.text();
  const parsed = safeParseJson(text);

  if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
    const body = parsed as Record<string, unknown>;
    const message =
      (typeof body.error === "string" && body.error.trim()) ||
      (typeof body.message === "string" && body.message.trim()) ||
      `Request failed with status ${response.status}`;

    return new ApiRequestError(response.status, message, body.details, parsed);
  }

  return new ApiRequestError(response.status, `Request failed with status ${response.status}`, undefined, parsed);
}

function buildConnectionErrorMessage(input: {
  apiBase: string;
  url: string;
  method: string;
  causeMessage?: string;
}): string {
  const healthUrl = buildHealthCheckUrl(input.url);
  const lines = [
    "Could not reach the Paperclip API.",
    "",
    `Request: ${input.method} ${input.url}`,
  ];
  if (input.causeMessage) {
    lines.push(`Cause: ${input.causeMessage}`);
  }
  lines.push(
    "",
    "This usually means the Paperclip server is not running, the configured URL is wrong, or the request is being blocked before it reaches Paperclip.",
    "",
    "Try:",
    "- Start Paperclip with `pnpm dev` or `pnpm paperclipai run`.",
    `- Verify the server is reachable with \`curl ${healthUrl}\`.`,
    `- If Paperclip is running elsewhere, pass \`--api-base ${input.apiBase.replace(/\/+$/, "")}\` or set \`PAPERCLIP_API_URL\`.`,
  );
  return lines.join("\n");
}

function buildHealthCheckUrl(requestUrl: string): string {
  const url = new URL(requestUrl);
  url.pathname = `${url.pathname.replace(/\/+$/, "").replace(/\/api(?:\/.*)?$/, "")}/api/health`;
  url.search = "";
  url.hash = "";
  return url.toString();
}

function formatConnectionCause(error: unknown): string | undefined {
  if (!error) return undefined;
  if (error instanceof Error) {
    return error.message.trim() || error.name;
  }
  const message = String(error).trim();
  return message || undefined;
}

function toStringRecord(headers: HeadersInit | undefined): Record<string, string> {
  if (!headers) return {};
  if (Array.isArray(headers)) {
    return Object.fromEntries(headers.map(([key, value]) => [key, String(value)]));
  }
  if (headers instanceof Headers) {
    return Object.fromEntries(headers.entries());
  }
  return Object.fromEntries(
    Object.entries(headers).map(([key, value]) => [key, String(value)]),
  );
}
