/**
 * Browser-side API access for the admin area (task 3.4).
 *
 * Public invitation pages are server-rendered so tokens never reach the browser. The admin
 * area is the opposite case: it is behind a session, it refreshes on an interval, and every
 * screen is interactive — so it calls the API directly from the client.
 *
 * That is safe because Caddy serves `/api/*` and the app from the same origin, so the
 * httpOnly session cookie rides along with no CORS and no token in JavaScript. It is also
 * *only* safe because authorization lives in FastAPI (task 8.1): everything here is a
 * convenience for the operator, never a security boundary. Hiding a nav item does not
 * protect the endpoint behind it — the `require(...)` dependency does.
 *
 * Types come from `schema.d.ts`, generated from OpenAPI. Nothing here is hand-typed.
 */
"use client";

import type { components } from "./schema";

type S = components["schemas"];

export type Session = S["SessionRead"];
export type DashboardStats = S["DashboardStats"];
export type EventStats = S["EventStats"];
export type GuestPage = S["GuestPage"];
export type GuestRead = S["GuestRead"];
export type GuestWrite = S["GuestWrite"];
export type InvitationSummary = S["InvitationSummary"];
export type ImportPreview = S["ImportPreview"];
export type ImportResult = S["ImportResult"];
export type ImportRowReport = S["ImportRowReport"];
export type SendPreview = S["SendPreview"];
export type SendResult = S["SendResult"];
export type MessageLogPage = S["MessageLogPage"];
export type MessageLogEntry = S["MessageLogEntry"];
export type WavePreview = S["WavePreview"];
export type AdminEvent = S["AdminEventRead"];
export type CardDesign = S["CardDesignRead"];
export type Wedding = S["WeddingRead"];
export type EventUpdateResult = S["EventUpdateResult"];
export type TrendPoint = S["TrendPoint"];
export type EventType = S["EventType"];
export type InvitationStatus = S["InvitationStatus"];
export type MessageJobStatus = S["MessageJobStatus"];
export type AdminRole = S["AdminRole"];
export type ReminderAudience = S["ReminderAudience"];
export type TemplatePurpose = S["TemplatePurpose"];
export type SendRequestBody = S["SendRequest"];
export type ManualResponse = S["ManualRsvpRequest"]["response"];
export type ComposedMessage = S["ComposedMessageRead"];
export type SendInvitationBody = S["SendInvitationRequest"];
export type SendInvitationResult = S["SendInvitationResult"];
export type BulkCompose = S["BulkComposeRead"];
export type ComposedPane = S["ComposedPaneRead"];
export type BulkPaneWrite = S["BulkPaneWrite"];
export type SendBatchBody = S["SendBatchRequest"];
export type SendBatchResult = S["SendBatchResult"];
export type BatchProgress = S["BatchProgressRead"];
export type Exclusion = S["ExclusionRead"];
export type InvitationType = S["InvitationType"];
export type AdminUser = S["AdminUserRead"];
export type AdminUserCreate = S["AdminUserCreate"];
export type AdminUserUpdate = S["AdminUserUpdate"];
export type TemporaryPassword = S["TemporaryPasswordRead"];
export type AdminStatus = S["AdminStatus"];
export type AuthMethod = S["AuthMethod"];
export type Locale = S["Locale"];

/** Thrown for any non-2xx so callers can branch on status rather than parse messages. */
export class ApiError extends Error {
  constructor(
    readonly status: number,
    message: string,
    /**
     * A machine-readable reason, when the API sent one.
     *
     * Some endpoints refuse a request for several different reasons at the same status —
     * a manual send returns 409 both for a guest who unsubscribed and for a send inside
     * quiet hours, and only one of those is worth offering to retry. Branching on the
     * message text would break the first time anyone reworded it.
     */
    readonly code?: string,
    /** Anything else the API attached to the refusal, e.g. `local_time`. */
    readonly context: Record<string, unknown> = {},
  ) {
    super(message);
    this.name = "ApiError";
  }

  /** The session expired or was never established. */
  get isUnauthenticated() {
    return this.status === 401;
  }

  /** Signed in, but this role may not do this. Distinct from 401 — re-authenticating
   * would not help, and telling the user to "sign in again" would be a dead end. */
  get isForbidden() {
    return this.status === 403;
  }
}

async function request<T>(path: string, init?: RequestInit): Promise<T> {
  const response = await fetch(`/api${path}`, {
    ...init,
    // Same origin, so this is the httpOnly session cookie and nothing else.
    credentials: "same-origin",
    headers: {
      accept: "application/json",
      ...(init?.body instanceof FormData ? {} : { "content-type": "application/json" }),
      ...init?.headers,
    },
    cache: "no-store",
  });

  if (!response.ok) {
    let detail = response.statusText;
    let code: string | undefined;
    let context: Record<string, unknown> = {};
    try {
      const body = await response.json();
      const raw = body?.detail;
      if (Array.isArray(raw)) {
        // FastAPI's validation errors: a list of per-field objects.
        detail = raw.map((d: { msg?: string }) => d.msg ?? "invalid value").join("; ");
      } else if (raw && typeof raw === "object") {
        // A refusal that named itself. Keep the code — see ApiError above.
        const { message, code: raised, ...rest } = raw as Record<string, unknown>;
        detail = typeof message === "string" ? message : detail;
        code = typeof raised === "string" ? raised : undefined;
        context = rest;
      } else {
        detail = raw ?? detail;
      }
    } catch {
      /* non-JSON error body; the status line is all we have */
    }
    throw new ApiError(response.status, detail, code, context);
  }

  return response.status === 204 ? (undefined as T) : ((await response.json()) as T);
}

const json = (body: unknown) => ({ body: JSON.stringify(body) });

export const api = {
  session: () => request<Session>("/auth/me"),
  signInWithGoogle: (idToken: string) =>
    request<Session>("/auth/google", { method: "POST", ...json({ id_token: idToken }) }),
  signInAsDev: (email: string) =>
    request<Session>("/auth/dev", { method: "POST", ...json({ email }) }),
  signInWithPassword: (username: string, password: string) =>
    request<Session>("/auth/password", { method: "POST", ...json({ username, password }) }),
  /**
   * Changes only the caller's own password. There is deliberately no "change someone
   * else's" call here — that is the roster's job and it produces a temporary password.
   */
  changeOwnPassword: (currentPassword: string, newPassword: string) =>
    request<Session>("/auth/password/change", {
      method: "POST",
      ...json({ current_password: currentPassword, new_password: newPassword }),
    }),
  signOut: () => request<void>("/auth/signout", { method: "POST" }),

  // --- the admin roster (Super Admin only; the API refuses everyone else) -------------
  adminUsers: () => request<AdminUser[]>("/admin/users"),
  createAdminUser: (body: AdminUserCreate) =>
    request<AdminUser>("/admin/users", { method: "POST", ...json(body) }),
  updateAdminUser: (id: string, body: AdminUserUpdate) =>
    request<AdminUser>(`/admin/users/${id}`, { method: "PATCH", ...json(body) }),
  activateAdminUser: (id: string, role: AdminRole) =>
    request<AdminUser>(`/admin/users/${id}/activate`, { method: "POST", ...json({ role }) }),
  rejectAdminUser: (id: string) =>
    request<AdminUser>(`/admin/users/${id}/reject`, { method: "POST" }),
  withdrawAdminUser: (id: string) =>
    request<AdminUser>(`/admin/users/${id}/withdraw`, { method: "POST" }),
  deleteAdminUser: (id: string) => request<void>(`/admin/users/${id}`, { method: "DELETE" }),
  /**
   * Returns the password once. Show it, let it be copied, and let it go — nothing here or
   * on the server can retrieve it again.
   */
  issueTemporaryPassword: (id: string) =>
    request<TemporaryPassword>(`/admin/users/${id}/temporary-password`, { method: "POST" }),
  transferEvent: (eventId: string, newOwnerId: string) =>
    request<AdminUser>(`/admin/events/${eventId}/transfer`, {
      method: "POST",
      ...json({ new_owner_id: newOwnerId }),
    }),

  stats: () => request<DashboardStats>("/admin/stats"),
  trend: (days: number) => request<TrendPoint[]>(`/admin/stats/trend?days=${days}`),

  events: () => request<AdminEvent[]>("/admin/events"),
  createEvent: (body: S["EventCreate"]) =>
    request<AdminEvent>("/admin/events", { method: "POST", ...json(body) }),
  eventDeleteImpact: (id: string) =>
    request<S["EventDeleteImpact"]>(`/admin/events/${id}/delete-impact`),
  deleteEvent: (id: string) => request<void>(`/admin/events/${id}`, { method: "DELETE" }),
  updateEvent: (id: string, patch: Partial<AdminEvent>) =>
    request<EventUpdateResult>(`/admin/events/${id}`, { method: "PATCH", ...json(patch) }),
  broadcastDateChange: (id: string, acceptedOnly: boolean) =>
    request<S["BroadcastResult"]>(`/admin/events/${id}/broadcast-date-change`, {
      method: "POST",
      ...json({ audience_accepted_only: acceptedOnly }),
    }),

  cardDesigns: (eventId: string) =>
    request<CardDesign[]>(`/admin/events/${eventId}/card-designs`),
  uploadCardDesign: (eventId: string, document: File, assets: File[]) => {
    const form = new FormData();
    form.append("document", document);
    // Repeated field name, not an array key: this is how multipart carries a list, and it
    // is what FastAPI's `list[UploadFile]` reads.
    for (const asset of assets) form.append("assets", asset);
    return request<CardDesign>(`/admin/events/${eventId}/card-designs`, {
      method: "POST",
      body: form,
    });
  },
  publishCardDesign: (id: string) =>
    request<CardDesign>(`/admin/card-designs/${id}/publish`, { method: "POST" }),
  unpublishCardDesign: (id: string) =>
    request<CardDesign>(`/admin/card-designs/${id}/unpublish`, { method: "POST" }),
  deleteCardDesign: (id: string) =>
    request<void>(`/admin/card-designs/${id}`, { method: "DELETE" }),

  wedding: () => request<Wedding>("/admin/wedding"),
  saveInvitationMessages: (messages: Record<string, Record<string, string>>) =>
    request<Wedding>("/admin/wedding/invitation-messages", {
      method: "PUT",
      ...json({ messages }),
    }),

  guests: (query: URLSearchParams) => request<GuestPage>(`/admin/guests?${query}`),
  eventGuests: (eventId: string, query: URLSearchParams) =>
    request<GuestPage>(`/admin/events/${eventId}/guests?${query}`),
  guest: (id: string) => request<GuestRead>(`/admin/guests/${id}`),
  // Creation is addressed by event: a guest belongs to exactly one (design D11), so there
  // is no route that could create one without saying which list it joins.
  createGuest: (eventId: string, guest: GuestWrite) =>
    request<GuestRead>(`/admin/events/${eventId}/guests`, { method: "POST", ...json(guest) }),
  updateGuest: (id: string, guest: Partial<GuestWrite>) =>
    request<GuestRead>(`/admin/guests/${id}`, { method: "PATCH", ...json(guest) }),
  deleteGuest: (id: string) => request<void>(`/admin/guests/${id}`, { method: "DELETE" }),
  overrideRsvp: (invitationId: string, body: S["ManualRsvpRequest"]) =>
    request<void>(`/admin/invitations/${invitationId}/rsvp`, { method: "POST", ...json(body) }),

  /** The message this guest would receive, composed server-side and ready to edit. */
  invitationMessage: (invitationId: string) =>
    request<ComposedMessage>(`/admin/invitations/${invitationId}/message`),
  /** Send it. Throws `ApiError` with `code: "quiet_hours"` when confirmation is needed. */
  sendInvitation: (invitationId: string, body: SendInvitationBody) =>
    request<SendInvitationResult>(`/admin/invitations/${invitationId}/send`, {
      method: "POST",
      ...json(body),
    }),

  /**
   * Record that an admin took this link to deliver by hand (design D8).
   *
   * Audit only — it sends nothing and moves no send state. Deliberately not awaited by the
   * caller: a failed log entry must not make a successful copy look like it failed.
   */
  recordLinkCopy: (invitationId: string) =>
    request<void>(`/admin/invitations/${invitationId}/link-copied`, { method: "POST" }),

  /**
   * Every guest matching these filters, for "select all N matching"
   * (add-bulk-invitation-send D2).
   *
   * Resolved to ids here rather than re-evaluated at send time, so the set the admin
   * approved is the set that gets sent.
   */
  eventGuestIds: (eventId: string, query: URLSearchParams) =>
    request<S["GuestIdsRead"]>(`/admin/events/${eventId}/guest-ids?${query}`),
  /** Both invitation-type messages for this selection, with placeholders intact. */
  composeBulkInvitation: (eventId: string, guestIds: string[]) =>
    request<BulkCompose>(`/admin/events/${eventId}/invitations/compose`, {
      method: "POST",
      ...json({ guest_ids: guestIds }),
    }),
  /** Record a batch. Throws `ApiError` with `code: "quiet_hours"` when confirmation is
   * needed, and with `code: "bad_placeholder"` when a pane will not send. */
  sendInvitationBatch: (eventId: string, body: SendBatchBody) =>
    request<SendBatchResult>(`/admin/events/${eventId}/invitations/send-batch`, {
      method: "POST",
      ...json(body),
    }),
  /** How a recorded batch is getting on. Polled while anything is still waiting (D7). */
  batchProgress: (batchId: string) =>
    request<BatchProgress>(`/admin/send-batches/${batchId}`),

  previewImport: (eventId: string, file: File) => {
    const form = new FormData();
    form.append("file", file);
    return request<ImportPreview>(`/admin/events/${eventId}/guests/import/preview`, {
      method: "POST",
      body: form,
    });
  },
  commitImport: (eventId: string, file: File) => {
    const form = new FormData();
    form.append("file", file);
    return request<ImportResult>(`/admin/events/${eventId}/guests/import`, {
      method: "POST",
      body: form,
    });
  },

  previewSend: (body: S["SendPreviewRequest"]) =>
    request<SendPreview>("/admin/send/preview", { method: "POST", ...json(body) }),
  send: (body: S["SendRequest"]) =>
    request<SendResult>("/admin/send", { method: "POST", ...json(body) }),
  messages: (query: URLSearchParams) => request<MessageLogPage>(`/admin/messages?${query}`),
  retryFailed: () => request<S["RetryResult"]>("/admin/messages/retry", { method: "POST" }),

  reminderPreview: () => request<WavePreview[]>("/admin/reminders/preview"),
  updateReminder: (id: string, body: S["ReminderScheduleWrite"]) =>
    request<void>(`/admin/reminders/${id}`, { method: "PATCH", ...json(body) }),
};

/** Export and QR are file downloads, so they are plain links rather than fetches — the
 * browser handles Content-Disposition and the cookie goes along on a same-origin GET. */
export const downloadUrl = {
  export: (params: URLSearchParams) => `/api/admin/export?${params}`,
  eventQr: (slug: string, format: "svg" | "png") => `/api/admin/qr/${slug}?format=${format}`,
  /**
   * One guest's QR. No screen calls this since the guest detail view stopped offering QR
   * artwork (add-guest-invitation-send D10); it is kept because the endpoint behind it is
   * what the per-guest print sheet (spec qr-codes, P1) will need when that is built.
   */
  guestQr: (invitationId: string, format: "svg" | "png" = "svg") =>
    `/api/admin/qr/guest/${invitationId}?format=${format}`,
};

/**
 * The guest-facing invitation link for a personalised token.
 *
 * Derived from the current origin rather than a configured base URL because Caddy serves
 * the admin area and the public invitation pages from the same host — so whatever host the
 * operator is looking at is, by construction, the one the guest must be sent to. A
 * separately configured value could disagree with reality and produce a link that 404s.
 */
export function inviteUrl(token: string): string {
  const origin = typeof window === "undefined" ? "" : window.location.origin;
  return `${origin}/i/${token}`;
}
