import type { HeadscaleServerCredentials } from '../db/queries';

// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
// wire-level quirks are handled once:
//
//  • Auth is `Authorization: Bearer <apiKey>`. Headscale's swagger declares no securityDefinitions at all,
//    so a generated client would omit it entirely.
//  • 401/403 bodies are PLAIN TEXT ("Unauthorized"), with no content-type — every other error is
//    grpc-gateway `{code,message,details}` JSON. Blindly .json()-ing an error body throws on exactly the
//    auth failure you most want to report clearly.
//  • Every uint64 is serialized as a JSON STRING, not a number: `node.id` arrives as "7". We keep ids as
//    strings end to end and never round-trip them through Number, which would silently break above 2^53.
//  • The gateway marshals with EmitUnpopulated, so absent values come back as [] / null / "" / false rather
//    than being omitted. You cannot distinguish "unset" from "empty" — don't try.
//  • It also marshals with DiscardUnknown, so a misspelled request field is IGNORED rather than rejected.
//    Silent no-ops are the failure mode; mutations here read the object back where the API returns it.

const DEFAULT_TIMEOUT_MS = 15_000;

/** An upstream failure carrying the HTTP status to surface, mapped to a response at the route boundary. */
export class HeadscaleError extends Error {
  constructor(
    readonly status: number,
    message: string,
    /**
     * Headscale's own words, kept even when `message` generalizes them.
     *
     * A 5xx is normally not safe to relay — it leaks internals and rarely helps. The policy endpoints are
     * the exception: Headscale answers "policy is read from a file" and reports a HuJSON syntax error's
     * line and column with the same 500, and there the message IS the feature. Callers that know their
     * endpoint's 5xx is a real answer read this; everyone else keeps getting "headscale error".
     */
    readonly detail?: string,
  ) {
    super(message);
    this.name = 'HeadscaleError';
  }
}

type CallOptions = { method?: string; body?: unknown; timeoutMs?: number };

/**
 * Extract a human-usable message from a Headscale error response, tolerating both of its formats.
 * Never returned verbatim to the browser for auth failures — see callers.
 */
async function errorMessage(res: Response): Promise<string> {
  const text = await res.text().catch(() => '');
  if (!text) return `upstream returned ${res.status}`;
  try {
    const parsed = JSON.parse(text) as { message?: unknown };
    if (typeof parsed.message === 'string' && parsed.message) return parsed.message;
  } catch {
    /* plain text — the 401 case */
  }
  return text.slice(0, 300);
}

export type HeadscaleClient = {
  readonly serverId: number;
  /** Call an admin API path (e.g. `/api/v1/node`). Throws HeadscaleError on any non-2xx. */
  call: <T>(path: string, opts?: CallOptions) => Promise<T>;
};

/** Build a client bound to one registered server's credentials. */
export function createClient(creds: HeadscaleServerCredentials): HeadscaleClient {
  async function call<T>(path: string, opts: CallOptions = {}): Promise<T> {
    const { method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;

    const headers: Record<string, string> = {
      authorization: `Bearer ${creds.apiKey}`,
      accept: 'application/json',
    };
    if (body !== undefined) headers['content-type'] = 'application/json';

    let res: Response;
    try {
      res = await fetch(`${creds.url}${path}`, {
        method,
        headers,
        body: body === undefined ? undefined : JSON.stringify(body),
        signal: AbortSignal.timeout(timeoutMs),
      });
    } catch (err) {
      const timedOut = err instanceof Error && err.name === 'TimeoutError';
      throw new HeadscaleError(504, timedOut ? 'headscale timed out' : 'headscale unreachable');
    }

    if (res.status === 401 || res.status === 403) {
      // The stored key is wrong, expired, or was revoked on the server. Actionable, and distinct from an
      // Officer-side auth problem — the UI should point the owner at re-entering the key.
      throw new HeadscaleError(502, 'headscale rejected the stored API key');
    }

    if (!res.ok) {
      const message = await errorMessage(res);
      console.error(`[headscale] ${method} ${path} -> ${res.status}: ${message}`);
      // 4xx from the admin API is usually a bad argument and safe to relay; 5xx is not, so it's generalized.
      const serverSide = res.status >= 500;
      throw new HeadscaleError(serverSide ? 502 : res.status, serverSide ? 'headscale error' : message, message);
    }

    // 204 and empty 200s are legitimate: several mutations return `{}` or nothing at all.
    const text = await res.text();
    if (!text) return {} as T;
    try {
      return JSON.parse(text) as T;
    } catch {
      throw new HeadscaleError(502, 'headscale returned a non-JSON body');
    }
  }

  return { serverId: creds.id, call };
}
