// Tiny fetch wrapper — Task 8 of docs/superpowers/plans/2026-07-31-local-app.md.
// baseURL is '' on purpose: in dev, Vite's proxy forwards /api to the Fastify
// dev server (web/vite.config.ts); in production the same Fastify process
// serves both the built web assets and /api/* from one origin (server/src/app.ts).
// So every call here is a same-origin relative path, always.
//
// Every /api/c/* route requires an X-Company-Id header (server/src/companyContext.ts);
// callers pass the selected company id in explicitly rather than this module
// reaching into React context, so it stays a plain, context-free helper.

// `body` carries the parsed JSON error response (when there was one) beyond
// just its `.error` string — Task 10's setup wizard needs the extra
// structured fields several endpoints attach alongside `error` (chart
// import's `conflicts`/`unknownTypes`/`duplicateNames`, import/post's
// `dirtyYears`) to render a detailed list, not just the one-line message.
// Optional and unused by every existing caller, so this is purely additive.
// `status` carries the HTTP status code alongside the message — AP Task 5's
// check-print run needs to distinguish a 409 (check-number collision, which
// gets its own "go fix it in Settings" link) from any other failure without
// resorting to matching on the English message text itself. Optional and
// unused by every existing caller, same additive spirit as `body` above.
export class ApiError extends Error {
  body?: unknown;
  status?: number;
  constructor(message: string, body?: unknown, status?: number) {
    super(message);
    this.body = body;
    this.status = status;
  }
}

interface RequestOptions {
  companyId?: string | null;
  body?: unknown;
}

function extractErrorMessage(status: number, data: unknown): string {
  if (data && typeof data === "object" && "error" in data) {
    const err = (data as { error?: unknown }).error;
    if (typeof err === "string" && err.trim().length > 0) return err;
  }
  return `Something went wrong talking to the server (HTTP ${status}).`;
}

async function request<T>(method: string, path: string, opts: RequestOptions = {}): Promise<T> {
  const headers: Record<string, string> = {};
  if (opts.body !== undefined) headers["Content-Type"] = "application/json";
  if (opts.companyId) headers["X-Company-Id"] = opts.companyId;

  let res: Response;
  try {
    res = await fetch(path, {
      method,
      headers,
      body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
    });
  } catch {
    throw new ApiError("Could not reach the server. Check that it's running and try again.");
  }

  const text = await res.text();
  let data: unknown = null;
  if (text) {
    try {
      data = JSON.parse(text);
    } catch {
      data = null;
    }
  }

  if (!res.ok) {
    throw new ApiError(extractErrorMessage(res.status, data), data, res.status);
  }
  return data as T;
}

export function apiGet<T>(path: string, companyId?: string | null): Promise<T> {
  return request<T>("GET", path, { companyId });
}

export function apiPost<T>(path: string, body?: unknown, companyId?: string | null): Promise<T> {
  return request<T>("POST", path, { companyId, body });
}

export function apiPatch<T>(path: string, body?: unknown, companyId?: string | null): Promise<T> {
  return request<T>("PATCH", path, { companyId, body });
}

export function apiPut<T>(path: string, body?: unknown, companyId?: string | null): Promise<T> {
  return request<T>("PUT", path, { companyId, body });
}

/** Used only for the two things this system genuinely throws away rather than
 * reverses: a never-issued purchase order and a never-entered draft bill.
 * Both have posted nothing, so there is nothing to reverse — everything that
 * HAS touched the books is voided or reversed, never deleted (CLAUDE.md §3.2). */
export function apiDelete<T>(path: string, companyId?: string | null): Promise<T> {
  return request<T>("DELETE", path, { companyId });
}
