/**
 * The PII boundary (design D8, task 8.2).
 *
 * Client component props are serialized into the HTML payload, so anything passed across
 * that line is effectively published. These mappers exist so no API object is ever spread
 * into a client component: each view model lists its fields explicitly, and adding a field
 * to the API does not silently leak it into the page source.
 *
 * The rule that matters: `toOpenEventView` must never carry guest data of any kind, because
 * `/e/{slug}` is reachable by anyone who scans a printed QR.
 */
import type {
  ApiCard,
  ApiCurrentRsvp,
  ApiEvent,
  ApiOpenEvent,
  ApiTokenInvitation,
} from "./api/server";

export type Locale = "bn" | "en";

export interface EventView {
  slug: string;
  type: string;
  themeKey: string;
  titleBn: string;
  titleEn: string;
  startsAt: string;
  endsAt: string | null;
  venueName: string;
  venueAddress: string;
  mapUrl: string | null;
  dressCode: string | null;
  notes: string | null;
  coverImageUrl: string | null;
  musicUrl: string | null;
  rsvpDeadline: string | null;
  /**
   * The host block at the foot of the invitation (design D8).
   *
   * Not an exception to the rule above: the host is a parent of the couple, and their name
   * and number are printed on the invitation precisely so guests can read and ring them.
   * Carrying them onto `/e/{slug}` is the point, not a leak — unlike anything about a guest.
   */
  hostName1: string;
  hostName2: string | null;
  hostPhone: string;
}

export interface CoupleView {
  brideName: string;
  groomName: string;
  displayName: string;
  defaultLocale: Locale;
  hostContactPhone: string | null;
}

/** Pre-fill values for the form. Only ever reaches the page behind a valid token. */
export interface PrefillView {
  name: string;
  email: string;
  phone: string;
}

export interface CurrentRsvpView {
  response: "accepted" | "declined" | "cancelled";
  partySize: number;
  messageToCouple: string | null;
  dietaryNotes: string | null;
}

/** The published card design (design D1, D3). Never carries guest data — the card is fixed
 * for the event, and the guest's name lives in the header above it. */
export interface CardView {
  renderer: "html" | "image" | "component" | "layered";
  /** `html`: the sanitised document, injected into a shadow root. */
  document: string | null;
  /** `image`: the artwork and its locale-resolved alternative text. */
  imageUrl: string | null;
  alt: string;
  width: number | null;
  height: number | null;
}

/** The header sentence, already resolved by the API — never empty (design D9). */
export interface GreetingView {
  invitationType: "single" | "family";
  message: string;
}

/** Which of the mutually exclusive states the invitation page should render. */
export type PageState =
  | "open"
  | "already_responded"
  | "declined"
  | "cancelled"
  | "closed";

/**
 * What a link to this invitation says when it is shown somewhere else (design D5).
 *
 * Guest-free on both routes, deliberately — it lands in meta tags a chat application
 * fetches and caches, so it must be identical for every guest of the event (design D3).
 * The guest's name reaches their email and the admin panel by a different path.
 */
export interface LinkPreviewView {
  title: string;
  description: string;
  canonicalUrl: string;
  siteName: string;
  image: { url: string; width: number; height: number } | null;
}

export interface InvitationView {
  token: string;
  state: PageState;
  maxGuests: number;
  shortCode: string;
  canCancel: boolean;
  event: EventView;
  couple: CoupleView;
  prefill: PrefillView | null;
  currentRsvp: CurrentRsvpView | null;
  greeting: GreetingView;
  card: CardView | null;
  preview: LinkPreviewView;
  locale: Locale;
}

export interface OpenEventView {
  event: EventView;
  couple: CoupleView;
  greeting: GreetingView;
  card: CardView | null;
  preview: LinkPreviewView;
  rsvpOpen: boolean;
  selectableEvents: { type: string; titleBn: string; titleEn: string }[];
}

function toEventView(event: ApiEvent): EventView {
  return {
    slug: event.slug,
    type: event.type,
    themeKey: event.theme_key,
    titleBn: event.title_bn,
    titleEn: event.title_en,
    startsAt: event.starts_at,
    endsAt: event.ends_at,
    venueName: event.venue_name,
    venueAddress: event.venue_address,
    mapUrl: event.map_url,
    dressCode: event.dress_code,
    notes: event.notes,
    coverImageUrl: event.cover_image_url,
    musicUrl: event.music_url,
    rsvpDeadline: event.rsvp_deadline,
    hostName1: event.host_name_1,
    hostName2: event.host_name_2,
    hostPhone: event.host_phone,
  };
}

function toCoupleView(couple: ApiTokenInvitation["couple"]): CoupleView {
  return {
    brideName: couple.bride_name,
    groomName: couple.groom_name,
    displayName: `${couple.bride_name} & ${couple.groom_name}`,
    defaultLocale: couple.default_locale,
    hostContactPhone: couple.host_contact_phone,
  };
}

/** Picks the locale's sentence from the pair the API returns, so the toggle costs no fetch. */
function toGreetingView(
  greeting: ApiTokenInvitation["greeting"],
  locale: Locale,
): GreetingView {
  return {
    invitationType: greeting.invitation_type,
    message: locale === "bn" ? greeting.message_bn : greeting.message_en,
  };
}

function toCardView(card: ApiCard, locale: Locale): CardView {
  return {
    renderer: card.renderer,
    document: card.document ?? null,
    imageUrl: card.image_url ?? null,
    // Resolved here rather than in the component: alt text is the card's words for anyone
    // who cannot see it, so it follows the same locale the rest of the page resolved to.
    alt: (locale === "bn" ? card.alt_bn : card.alt_en) || "",
    width: card.width ?? null,
    height: card.height ?? null,
  };
}

function toLinkPreviewView(preview: ApiTokenInvitation["preview"]): LinkPreviewView {
  return {
    title: preview.title,
    description: preview.description,
    canonicalUrl: preview.canonical_url,
    siteName: preview.site_name,
    image: preview.image
      ? {
          url: preview.image.url,
          width: preview.image.width,
          height: preview.image.height,
        }
      : null,
  };
}

function toCurrentRsvpView(rsvp: ApiCurrentRsvp): CurrentRsvpView {
  return {
    response: rsvp.response,
    partySize: rsvp.party_size,
    messageToCouple: rsvp.message_to_couple,
    dietaryNotes: rsvp.dietary_notes,
  };
}

function resolvePageState(data: ApiTokenInvitation): PageState {
  if (data.status === "accepted") return "already_responded";
  if (data.status === "declined") return "declined";
  if (data.status === "cancelled") return "cancelled";
  // Deadline check comes last: a guest who already answered still sees their answer.
  if (!data.rsvp_open || data.status === "expired") return "closed";
  return "open";
}

export function toInvitationView(
  data: ApiTokenInvitation,
  token: string,
  localeOverride?: Locale,
): InvitationView {
  // Resolved once, up front: the greeting depends on it and so does every date on the page.
  const locale: Locale = localeOverride ?? data.guest?.preferred_locale ?? "en";
  return {
    token,
    state: resolvePageState(data),
    maxGuests: data.max_guests,
    shortCode: data.short_code,
    canCancel: data.can_cancel,
    event: toEventView(data.event),
    couple: toCoupleView(data.couple),
    prefill: data.guest
      ? {
          name: data.guest.full_name,
          email: data.guest.email ?? "",
          phone: data.guest.phone_e164 ?? "",
        }
      : null,
    currentRsvp: data.current_rsvp ? toCurrentRsvpView(data.current_rsvp) : null,
    greeting: toGreetingView(data.greeting, locale),
    card: data.card ? toCardView(data.card, locale) : null,
    // Not resolved against `locale`: a per-guest locale would make two guests' meta tags
    // differ, which is the one property the tokenized route's safety depends on.
    preview: toLinkPreviewView(data.preview),
    // Explicit toggle wins, then the guest's stored preference, then English (design D13).
    locale,
  };
}

export function toOpenEventView(data: ApiOpenEvent): OpenEventView {
  return {
    event: toEventView(data.event),
    couple: toCoupleView(data.couple),
    // The open route has no guest, so the sentence renders with no name line above it.
    greeting: toGreetingView(data.greeting, data.couple.default_locale),
    card: data.card ? toCardView(data.card, data.couple.default_locale) : null,
    preview: toLinkPreviewView(data.preview),
    rsvpOpen: data.rsvp_open,
    // Deliberately no guest data: this page is public.
    selectableEvents: data.selectable_events.map((e) => ({
      type: e.type,
      titleBn: e.title_bn,
      titleEn: e.title_en,
    })),
  };
}

// ---------------------------------------------------------------- the admin roster

/**
 * One admin account as the roster screen shows it (add-admin-access-control task 9.10).
 *
 * The same PII rule as everything above, pointed at a different population: a roster is a
 * list of real people's email addresses, rendered by a client component, so every field that
 * crosses this line is published in the page source. Listed explicitly for the usual reason —
 * adding a column to `AdminUserRead` must not silently put it in the HTML — and for one
 * specific to this table: nothing here can carry a credential, so a `password_hash` appearing
 * on the API model tomorrow cannot arrive by accident.
 */
export interface AdminUserView {
  id: string;
  email: string;
  name: string | null;
  username: string | null;
  role: string;
  status: string;
  authMethod: string;
  /** An onboarding somebody started and nobody finished. Never the password itself. */
  temporaryPasswordOutstanding: boolean;
  locked: boolean;
  firstSeenAt: string | null;
  lastLoginAt: string | null;
  ownedEventCount: number;
}

/** Shape of the API's roster row, kept local so this file imports no admin client. */
interface ApiAdminUser {
  id: string;
  email: string;
  name: string | null;
  username: string | null;
  role: string;
  status: string;
  auth_method: string;
  temporary_password_outstanding: boolean;
  locked: boolean;
  first_seen_at: string | null;
  last_login_at: string | null;
  owned_event_count: number;
}

export function toAdminUserView(data: ApiAdminUser): AdminUserView {
  return {
    id: data.id,
    email: data.email,
    name: data.name,
    username: data.username,
    role: data.role,
    status: data.status,
    authMethod: data.auth_method,
    temporaryPasswordOutstanding: data.temporary_password_outstanding,
    locked: data.locked,
    firstSeenAt: data.first_seen_at,
    lastLoginAt: data.last_login_at,
    ownedEventCount: data.owned_event_count,
  };
}

/** The signed-in admin's own account, for profile settings. */
export interface ProfileView {
  email: string;
  name: string | null;
  username: string | null;
  role: string;
  authMethod: string;
  /** Whether to render the password section at all — a Google account has none to change. */
  canChangePassword: boolean;
}

export function toProfileView(session: {
  email: string;
  name: string | null;
  username: string | null;
  role: string;
  auth_method: string;
}): ProfileView {
  return {
    email: session.email,
    name: session.name,
    username: session.username,
    role: session.role,
    authMethod: session.auth_method,
    canChangePassword: session.auth_method === "password",
  };
}
