/**
 * The API client.
 *
 * One place that knows how to attach credentials and interpret failures, so no
 * component has to. The distinction that matters is between 401 (not signed
 * in) and 403 `needs_profile` (signed in, but no PIG account) — those need
 * completely different screens, and collapsing them into "auth error" produces
 * the classic bug where an invited user is bounced to a login page they have
 * already completed.
 */
import { createClient, type SupabaseClient } from '@supabase/supabase-js';

export interface PublicConfig {
  supabaseUrl: string | null;
  supabaseAnonKey: string | null;
  authDisabled: boolean;
  inviteRequired: boolean;
  canSelfRegister: boolean;
  accents: { key: string; label: string }[];
  teams: string[];
}

export class ApiError extends Error {
  constructor(
    message: string,
    readonly status: number,
    readonly code?: string,
  ) {
    super(message);
    this.name = 'ApiError';
  }

  get needsProfile(): boolean {
    return this.status === 403 && this.code === 'needs_profile';
  }

  get needsSignIn(): boolean {
    return this.status === 401;
  }
}

let supabase: SupabaseClient | null = null;
let publicConfig: PublicConfig | null = null;
/*
 * The in-flight load, not just its result.
 *
 * Guarding on the resolved config alone is not enough: StrictMode invokes the
 * effect that calls this twice, both calls observe a null config, and both go
 * on to build an auth client. Two GoTrueClients then share one storage key and
 * refresh the same session against each other — which Supabase warns about and
 * which only bites where auth is actually configured, so it never shows up in
 * a development run with auth disabled. Caching the promise makes the second
 * caller await the first rather than race it.
 */
let publicConfigLoad: Promise<PublicConfig> | null = null;

export function loadPublicConfig(): Promise<PublicConfig> {
  if (publicConfig) return Promise.resolve(publicConfig);
  if (publicConfigLoad) return publicConfigLoad;

  publicConfigLoad = (async () => {
    const response = await fetch('/api/config');
    if (!response.ok) throw new Error('Could not load configuration from the server.');
    const loaded = (await response.json()) as PublicConfig;

    if (loaded.supabaseUrl && loaded.supabaseAnonKey) {
      supabase = createClient(loaded.supabaseUrl, loaded.supabaseAnonKey, {
        auth: {
          persistSession: true,
          autoRefreshToken: true,
          // The session lands in a URL fragment after an email link; picking it
          // up automatically is what makes magic-link sign-in work.
          detectSessionInUrl: true,
        },
      });
    }
    publicConfig = loaded;
    return loaded;
  })();

  // A failed load must not be cached, or a transient network error becomes a
  // permanent one that only a reload can clear.
  publicConfigLoad.catch(() => {
    publicConfigLoad = null;
  });

  return publicConfigLoad;
}

export function getSupabase(): SupabaseClient | null {
  return supabase;
}

export function getPublicConfig(): PublicConfig | null {
  return publicConfig;
}

async function authHeader(): Promise<Record<string, string>> {
  if (!supabase) return {};
  // getSession refreshes an expired token transparently, which is why this is
  // read per request rather than cached at sign-in.
  const { data } = await supabase.auth.getSession();
  const token = data.session?.access_token;
  return token ? { authorization: `Bearer ${token}` } : {};
}

export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
  const response = await fetch(path, {
    ...init,
    headers: {
      'content-type': 'application/json',
      ...(await authHeader()),
      ...(init.headers ?? {}),
    },
  });

  if (!response.ok) {
    let message = response.statusText;
    let code: string | undefined;
    try {
      const body = (await response.json()) as { error?: string; code?: string };
      message = body.error ?? message;
      code = body.code;
    } catch {
      /* Not every error response is JSON — a proxy may return HTML. */
    }
    throw new ApiError(message, response.status, code);
  }

  // 204 and empty bodies are legitimate; parsing them would throw.
  if (response.status === 204) return undefined as T;
  const text = await response.text();
  return (text ? JSON.parse(text) : undefined) as T;
}

export const get = <T,>(path: string) => api<T>(path);
export const post = <T,>(path: string, body: unknown) =>
  api<T>(path, { method: 'POST', body: JSON.stringify(body) });
export const patch = <T,>(path: string, body: unknown) =>
  api<T>(path, { method: 'PATCH', body: JSON.stringify(body) });

// ---------------------------------------------------------------- formatting

/**
 * Money, from integer cents.
 *
 * Compact above a million because a pipeline view showing "$12,400,000.00" in
 * a phone-width column is unreadable, and the exact cent is never the point at
 * that magnitude.
 *
 * Not for prices quoted per GPU-hour — use `unitPrice`, which explains why.
 */
export function money(cents: number | null | undefined, currency = 'USD'): string {
  if (cents == null) return '—';
  const value = cents / 100;
  const compact = Math.abs(value) >= 1_000_000;
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency,
    notation: compact ? 'compact' : 'standard',
    maximumFractionDigits: compact ? 1 : value % 1 === 0 ? 0 : 2,
  }).format(value);
}

/** Money at full precision — for detail views where the cent does matter. */
export function moneyExact(cents: number | null | undefined, currency = 'USD'): string {
  if (cents == null) return '—';
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency,
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  }).format(cents / 100);
}

/**
 * A price quoted per GPU-hour. Always exact, and this is not negotiable.
 *
 * `money` hides the cents when they happen to be round, which is right for a
 * $2.4M deal and wrong here: at this scale the cents *are* the number. A
 * break-even of 200 cents rendered as "$2" on Overview and "$2.00" on Margin
 * is the same figure looking like two different figures, and the reader who
 * spots it stops trusting both screens. Every $/GPU-hr — cost, break-even,
 * quote, the delta between them — goes through this function, so the decision
 * is made once here rather than re-argued at each call site.
 */
export function unitPrice(cents: number | null | undefined, currency = 'USD'): string {
  return moneyExact(cents, currency);
}

export function percent(value: number | null | undefined, digits = 0): string {
  if (value == null || !Number.isFinite(value)) return '—';
  return `${(value * 100).toFixed(digits)}%`;
}

export function compactNumber(value: number | null | undefined): string {
  if (value == null) return '—';
  return new Intl.NumberFormat('en-US', {
    notation: Math.abs(value) >= 10_000 ? 'compact' : 'standard',
    maximumFractionDigits: 1,
  }).format(value);
}

const DAY = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' });
const DAY_AND_YEAR = new Intl.DateTimeFormat('en-US', {
  month: 'short',
  day: 'numeric',
  year: 'numeric',
});

function toDate(value: string | Date | null | undefined): Date | null {
  if (!value) return null;
  const date = typeof value === 'string' ? new Date(value) : value;
  return Number.isNaN(date.getTime()) ? null : date;
}

function formatDay(date: Date, withYear: boolean): string {
  return (withYear ? DAY_AND_YEAR : DAY).format(date);
}

/**
 * A day, carrying its year only when that year is not the current one.
 *
 * The year used to be omitted unconditionally, which is fine for "renewal
 * notice due Nov 3" and disastrous for a commitment window: a term is very
 * often exactly 365 days, so both ends land in the same month and the window
 * rendered as "Aug 13 – Aug 12" — a range that reads as running backwards.
 * Suppressing the year only when it is the one the reader is already in keeps
 * the common case short without ever printing a date that misleads.
 */
export function shortDate(value: string | Date | null | undefined): string {
  const date = toDate(value);
  if (!date) return '—';
  return formatDay(date, date.getFullYear() !== new Date().getFullYear());
}

/**
 * Both ends of a window, in one string.
 *
 * Ranges need a rule `shortDate` cannot apply alone: when the two ends fall in
 * different years, *both* need labelling, because "Aug 13 – Aug 12, 2027"
 * leaves the reader to guess at the start. A range that sits wholly inside a
 * single past or future year states that year once, at the end, rather than
 * twice.
 */
export function dateRange(
  start: string | Date | null | undefined,
  end: string | Date | null | undefined,
): string {
  const from = toDate(start);
  const to = toDate(end);
  if (!from || !to) return shortDate(from ?? to);

  const spansYears = from.getFullYear() !== to.getFullYear();
  const currentYear = new Date().getFullYear();
  const thisYear = !spansYears && from.getFullYear() === currentYear;
  return `${formatDay(from, spansYears)} – ${formatDay(to, !thisYear)}`;
}

export function relativeTime(value: string | Date | null | undefined): string {
  const date = toDate(value);
  if (!date) return '—';

  const seconds = Math.round((date.getTime() - Date.now()) / 1000);
  const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
  const divisions: [number, Intl.RelativeTimeFormatUnit][] = [
    [60, 'second'],
    [60, 'minute'],
    [24, 'hour'],
    [7, 'day'],
    [4.34524, 'week'],
    [12, 'month'],
    [Number.POSITIVE_INFINITY, 'year'],
  ];

  let duration = seconds;
  for (const [amount, unit] of divisions) {
    if (Math.abs(duration) < amount) return formatter.format(Math.round(duration), unit);
    duration /= amount;
  }
  return formatter.format(Math.round(duration), 'year');
}
