// Translates raw API/audit log records into the human-readable strings the
// admin slider renders. Output is shaped as { translationKey, values } pairs
// so next-intl handles localization — no English literals leak through.

export type ApiSeverity = "critical" | "warning" | "bot" | "ok";

export interface ApiLogShape {
  method: string;
  url: string;
  status_code: number;
  error_message: string | null;
  request_body: object | null;
  user_agent: string | null;
  ip_address: string | null;
  actor_name: string | null;
  actor_role: string | null;
}

export interface ApiLogClassification {
  severity: ApiSeverity;
  severityLabelKey: string;
  headlineKey: string;
  headlineValues: Record<string, string>;
  tipKey: string;
  isBotScan: boolean;
  pageNameKey: string | null;
  pageNameFallback: string;
}

// Suspicious paths/payloads. Anything matching here on a 4xx is treated as
// automated attack noise rather than a real user error. Patterns are intentionally
// conservative — false positives would hide real problems.
const BOT_PATH_PATTERNS = [
  /\/\.env(\.|$|\/)/i,
  /\/\.git(\/|$)/i,
  /\/\.aws(\/|$)/i,
  /\/\.htaccess$/i,
  /\/\.well-known\/(?!acme-challenge|change-password|security\.txt)/i,
  /\/wp-(admin|login|content|includes|config)/i,
  /\/wordpress\//i,
  /\/phpmyadmin/i,
  /\/pma\//i,
  /\/mysql\//i,
  /\/server-status/i,
  /\/cgi-bin/i,
  /\/etc\/passwd/i,
  /\/config\/(database|master|secret)/i,
  /\/WEB-INF\//i,
  /\/META-INF\//i,
  /\.(php|asp|aspx|jsp|cgi)(\?|$)/i,
  /\/(adminer|webmail|owa|autodiscover)/i,
  /\/vendor\/phpunit/i,
  /\/actuator(\/|$)/i,
  /\/console(\/|$)/i,
  /\/eval-stdin/i,
  /\/HNAP1/i,
  /\/boaform\//i,
  /\/manager\/html/i,
];

const BOT_BODY_TOKENS = [
  "androxgh0st",
  "androxghost",
  "0x[",
  "/bin/sh",
  "passthru(",
  "system(",
  "shell_exec(",
  "<?php",
  "${jndi:",
  "log4j",
];

const BOT_USER_AGENT_TOKENS = [
  "sqlmap",
  "nmap",
  "masscan",
  "nikto",
  "wpscan",
  "fuzz",
  "havij",
  "acunetix",
];

export function isBotScan(log: ApiLogShape): boolean {
  // Successful and server-error requests are never classified as bot scans —
  // a 200 isn't probe noise, and a 500 is a real bug we never want hidden.
  if (log.status_code >= 200 && log.status_code < 400) return false;
  if (log.status_code >= 500) return false;

  const url = log.url || "";
  if (BOT_PATH_PATTERNS.some((p) => p.test(url))) return true;

  const ua = (log.user_agent || "").toLowerCase();
  if (BOT_USER_AGENT_TOKENS.some((t) => ua.includes(t))) return true;

  if (log.request_body) {
    const body = JSON.stringify(log.request_body).toLowerCase();
    if (BOT_BODY_TOKENS.some((t) => body.includes(t))) return true;
  }

  // POST/PUT/DELETE/PATCH to root or unknown short paths with no actor — likely a probe.
  if (
    !log.actor_name &&
    log.method !== "GET" &&
    (url === "/" || url === "" || /^\/[a-z]{1,4}$/i.test(url))
  ) {
    return true;
  }

  return false;
}

// Map URL paths → translation key for a human feature name. Keys reference
// `admin.apiLogs.feature.*` entries in the message catalog. Order matters —
// more specific patterns must come before more general ones.
const FEATURE_RULES: Array<{ pattern: RegExp; key: string }> = [
  { pattern: /^\/?v\d+\/admin\/users/i, key: "admin.apiLogs.feature.userManagement" },
  { pattern: /^\/?v\d+\/admin\/notifications/i, key: "admin.apiLogs.feature.notifications" },
  { pattern: /^\/?v\d+\/admin\/transactions/i, key: "admin.apiLogs.feature.transactions" },
  { pattern: /^\/?v\d+\/admin\/subscriptions?/i, key: "admin.apiLogs.feature.subscriptions" },
  { pattern: /^\/?v\d+\/audit-logs/i, key: "admin.apiLogs.feature.auditLogs" },
  { pattern: /^\/?v\d+\/api-logs/i, key: "admin.apiLogs.feature.apiLogs" },
  { pattern: /^\/?v\d+\/auth\/login/i, key: "admin.apiLogs.feature.login" },
  { pattern: /^\/?v\d+\/auth\/sign-?up/i, key: "admin.apiLogs.feature.signup" },
  { pattern: /^\/?v\d+\/auth\/forgot-password/i, key: "admin.apiLogs.feature.forgotPassword" },
  { pattern: /^\/?v\d+\/auth\/reset-password/i, key: "admin.apiLogs.feature.resetPassword" },
  { pattern: /^\/?v\d+\/auth/i, key: "admin.apiLogs.feature.authentication" },
  { pattern: /^\/?v\d+\/clients?/i, key: "admin.apiLogs.feature.clients" },
  { pattern: /^\/?v\d+\/screening-reports?/i, key: "admin.apiLogs.feature.screeningReports" },
  { pattern: /^\/?v\d+\/screenings?/i, key: "admin.apiLogs.feature.screenings" },
  { pattern: /^\/?v\d+\/leads?/i, key: "admin.apiLogs.feature.leads" },
  { pattern: /^\/?v\d+\/documents?/i, key: "admin.apiLogs.feature.documents" },
  { pattern: /^\/?v\d+\/bibliography/i, key: "admin.apiLogs.feature.bibliography" },
  { pattern: /^\/?v\d+\/support/i, key: "admin.apiLogs.feature.support" },
  { pattern: /^\/?v\d+\/settings/i, key: "admin.apiLogs.feature.settings" },
  { pattern: /^\/?v\d+\/dashboard/i, key: "admin.apiLogs.feature.dashboard" },
];

export function featureKeyForUrl(url: string): string | null {
  if (!url) return null;
  // Strip query string before matching so `/v1/admin/users?page=1` still hits the rule.
  const path = url.split("?")[0];
  for (const rule of FEATURE_RULES) {
    if (rule.pattern.test(path)) return rule.key;
  }
  return null;
}

// Truncate path so the slider doesn't blow up when an attacker pastes a 5KB URL.
function shortPath(url: string, max = 80): string {
  if (!url) return "/";
  return url.length > max ? url.slice(0, max) + "…" : url;
}

export function classifyApiLog(log: ApiLogShape): ApiLogClassification {
  const code = log.status_code;
  const url = log.url || "/";
  const featureKey = featureKeyForUrl(url);
  const pageNameFallback = shortPath(url, 60);
  const bot = isBotScan(log);

  // Bot scan branch — short-circuits because both label and tip are the same
  // regardless of the underlying status code.
  if (bot) {
    return {
      severity: "bot",
      severityLabelKey: "admin.apiLogs.severity.bot",
      headlineKey: "admin.apiLogs.headline.botScan",
      headlineValues: { path: pageNameFallback },
      tipKey: "admin.apiLogs.tip.botScan",
      isBotScan: true,
      pageNameKey: featureKey,
      pageNameFallback,
    };
  }

  // 5xx — always critical. We don't need to distinguish auth/clients/etc;
  // the page name fact below already tells the admin where it broke.
  if (code >= 500) {
    return {
      severity: "critical",
      severityLabelKey: "admin.apiLogs.severity.critical",
      headlineKey: featureKey
        ? "admin.apiLogs.headline.serverError"
        : "admin.apiLogs.headline.serverErrorGeneric",
      headlineValues: {},
      tipKey: "admin.apiLogs.tip.serverError",
      isBotScan: false,
      pageNameKey: featureKey,
      pageNameFallback,
    };
  }

  // 401/403 — split between login failures and other auth issues so the
  // headline and tip read as the actual scenario the admin sees most often.
  if (code === 401 || code === 403) {
    const isLogin = /\/auth\/(login|sign-?in)/i.test(url);
    if (isLogin) {
      return {
        severity: "warning",
        severityLabelKey: "admin.apiLogs.severity.warning",
        headlineKey: "admin.apiLogs.headline.loginFailed",
        headlineValues: {},
        tipKey: "admin.apiLogs.tip.loginFailed",
        isBotScan: false,
        pageNameKey: featureKey,
        pageNameFallback,
      };
    }
    return {
      severity: "warning",
      severityLabelKey: "admin.apiLogs.severity.warning",
      headlineKey: log.actor_name
        ? "admin.apiLogs.headline.permissionDenied"
        : "admin.apiLogs.headline.permissionDeniedAnon",
      headlineValues: log.actor_name ? { actor: log.actor_name } : {},
      tipKey: "admin.apiLogs.tip.permissionDenied",
      isBotScan: false,
      pageNameKey: featureKey,
      pageNameFallback,
    };
  }

  if (code === 404) {
    return {
      severity: "warning",
      severityLabelKey: "admin.apiLogs.severity.warning",
      headlineKey: "admin.apiLogs.headline.notFound",
      headlineValues: { path: pageNameFallback },
      tipKey: "admin.apiLogs.tip.notFound",
      isBotScan: false,
      pageNameKey: featureKey,
      pageNameFallback,
    };
  }

  if (code === 429) {
    return {
      severity: "warning",
      severityLabelKey: "admin.apiLogs.severity.warning",
      headlineKey: "admin.apiLogs.headline.rateLimit",
      headlineValues: { ip: log.ip_address || "unknown" },
      tipKey: "admin.apiLogs.tip.rateLimit",
      isBotScan: false,
      pageNameKey: featureKey,
      pageNameFallback,
    };
  }

  if (code === 400 || code === 422) {
    return {
      severity: "warning",
      severityLabelKey: "admin.apiLogs.severity.warning",
      headlineKey: "admin.apiLogs.headline.invalidInput",
      headlineValues: {},
      tipKey: "admin.apiLogs.tip.invalidInput",
      isBotScan: false,
      pageNameKey: featureKey,
      pageNameFallback,
    };
  }

  if (code >= 200 && code < 400) {
    return {
      severity: "ok",
      severityLabelKey: "admin.apiLogs.severity.ok",
      headlineKey: "admin.apiLogs.headline.ok",
      headlineValues: {},
      tipKey: "admin.apiLogs.tip.ok",
      isBotScan: false,
      pageNameKey: featureKey,
      pageNameFallback,
    };
  }

  // Catch-all (e.g. 4xx codes we haven't named). Treat as a generic warning
  // so unknown statuses still get a friendly framing instead of a number.
  return {
    severity: "warning",
    severityLabelKey: "admin.apiLogs.severity.warning",
    headlineKey: "admin.apiLogs.headline.genericWarning",
    headlineValues: { code: String(code) },
    tipKey: "admin.apiLogs.tip.genericWarning",
    isBotScan: false,
    pageNameKey: featureKey,
    pageNameFallback,
  };
}

// Tiny user-agent parser — covers the common browsers/OSes admins will see.
// Returns translation keys (admin.apiLogs.browser.*, admin.apiLogs.os.*)
// when known so the slider can localize. Order is critical: Edge identifies
// itself as Chrome+Edg, Opera as Chrome+OPR — match those before plain Chrome.
export interface ParsedUserAgent {
  browserKey: string | null;
  browserVersion: string | null;
  osKey: string | null;
  raw: string;
}

export function parseUserAgent(ua: string | null): ParsedUserAgent {
  const empty: ParsedUserAgent = {
    browserKey: null,
    browserVersion: null,
    osKey: null,
    raw: ua || "",
  };
  if (!ua) return empty;

  let browserKey: string | null = null;
  let version: string | null = null;
  const browsers: Array<[RegExp, string]> = [
    [/Edg\/([\d.]+)/i, "admin.apiLogs.browser.edge"],
    [/OPR\/([\d.]+)/i, "admin.apiLogs.browser.opera"],
    [/Chrome\/([\d.]+)/i, "admin.apiLogs.browser.chrome"],
    [/Firefox\/([\d.]+)/i, "admin.apiLogs.browser.firefox"],
    [/Version\/([\d.]+).*Safari/i, "admin.apiLogs.browser.safari"],
    [/MSIE ([\d.]+)/i, "admin.apiLogs.browser.ie"],
    [/Trident.*rv:([\d.]+)/i, "admin.apiLogs.browser.ie"],
  ];
  for (const [re, key] of browsers) {
    const m = ua.match(re);
    if (m) {
      browserKey = key;
      version = m[1].split(".")[0];
      break;
    }
  }

  let osKey: string | null = null;
  if (/Windows/i.test(ua)) osKey = "admin.apiLogs.os.windows";
  else if (/iPad|iPhone|iPod/i.test(ua)) osKey = "admin.apiLogs.os.ios";
  else if (/Android/i.test(ua)) osKey = "admin.apiLogs.os.android";
  else if (/Mac OS X|Macintosh/i.test(ua)) osKey = "admin.apiLogs.os.macos";
  else if (/Linux/i.test(ua)) osKey = "admin.apiLogs.os.linux";

  return { browserKey, browserVersion: version, osKey, raw: ua };
}

// ──────────────────────────────────────────────────────────────────────────
// Audit logs
// ──────────────────────────────────────────────────────────────────────────

export interface AuditLogShape {
  action: string;
  resource_type: string;
  resource_id: string | number | null;
  actor_name: string | null;
  actor_role: string | null;
  // Backend-supplied free-form metadata. We pull `client_name` / `client_id`
  // from here when present so the headline can say "for Sarah Khan" instead
  // of the generic "for a client". Optional — old rows have only `params`.
  details?: object | null;
}

export interface AuditLogHumanized {
  headlineKey: string;
  // Static placeholder values (actor, id, raw action). The {resource} placeholder
  // is resolved by the page after looking up `resourceNameKey` via t().
  headlineValues: Record<string, string | number>;
  // Translation key for the noun in the headline (e.g. "client" / "clients"),
  // or null when the headline doesn't reference a resource (e.g. "submitted a screening").
  resourceNameKey: string | null;
  tipKey: string | null;
  // When backend populated `details.client_name`, surface it here so the slider
  // can render a "Client" quick-fact even on actions whose resource is the
  // screening, not the client.
  clientName: string | null;
  clientId: string | null;
}

// Pull client info out of the loosely-typed details blob safely. We try three
// sources in priority order so the slider still has something to show even if
// backend hasn't fully populated `context` for every event:
//
//   1. `details.context.client_name` + `client_id` — preferred (post-migration)
//   2. `details.context.client_uuid` — fallback id when numeric id is missing
//   3. `details.request_body.client_name` / `client_uuid` — last-resort for
//      events where the actor sent client info in the request itself
//
// Older rows that have none of these still return { id: null, name: null }.
function clientFromDetails(details: object | null | undefined): {
  id: string | null;
  name: string | null;
} {
  const pickString = (v: unknown): string | null =>
    typeof v === "string" && v.trim() ? v.trim() : null;
  const pickIdLike = (v: unknown): string | null =>
    typeof v === "string" || typeof v === "number" ? String(v) : null;

  if (!details || typeof details !== "object") return { id: null, name: null };
  const root = details as Record<string, unknown>;

  let id: string | null = null;
  let name: string | null = null;

  // 1. Preferred: details.context.*
  const ctx = root.context;
  if (ctx && typeof ctx === "object") {
    const c = ctx as Record<string, unknown>;
    name = pickString(c.client_name);
    id = pickIdLike(c.client_id) ?? pickIdLike(c.client_uuid);
  }

  // 2. Fallback: details.request_body.* (rare, but covers events created before
  //    backend's context migration when the body itself carried client info).
  if (!name || !id) {
    const body = root.request_body;
    if (body && typeof body === "object") {
      const b = body as Record<string, unknown>;
      name = name ?? pickString(b.client_name);
      id = id ?? pickIdLike(b.client_id) ?? pickIdLike(b.client_uuid);
    }
  }

  return { id, name };
}

// `details.request_body` carries inputs the actor submitted — we use it for
// invite/register actions where the headline benefits from the new user's
// email/name/role, and for `password_reset` where the presence of `token`
// distinguishes a completed reset from a request.
function bodyFromDetails(details: object | null | undefined): Record<string, unknown> {
  if (!details || typeof details !== "object") return {};
  const body = (details as Record<string, unknown>).request_body;
  return body && typeof body === "object" ? (body as Record<string, unknown>) : {};
}

// Coerce a body field to a clean string or null. Skips empty strings, numbers
// turned into "[object Object]", etc.
function bodyString(body: Record<string, unknown>, key: string): string | null {
  const v = body[key];
  if (typeof v === "string" && v.trim()) return v.trim();
  if (typeof v === "number") return String(v);
  return null;
}

// ──────────────────────────────────────────────────────────────────────────
// Request-body facts — friendly display of `details.request_body` fields so
// the admin can see "Email: john@x.com / Name: John Smith" instead of opening
// the raw JSON. Only top-level scalars are shown; nested objects/arrays and
// known-sensitive fields (password, token, …) are filtered out.
// ──────────────────────────────────────────────────────────────────────────

export interface RequestBodyFact {
  labelKey: string; // translation key the slider resolves with t()
  value: string;
}

// Maps known body keys to their translation key + display order. Lower order
// numbers float to the top of the list so the most-useful facts (email, name)
// appear first regardless of the JSON key order from backend.
const BODY_FIELD_LABELS: Record<string, { labelKey: string; order: number }> = {
  email: { labelKey: "admin.auditLogs.fact.email", order: 1 },
  username: { labelKey: "admin.auditLogs.fact.username", order: 2 },
  name: { labelKey: "admin.auditLogs.fact.name", order: 3 },
  full_name: { labelKey: "admin.auditLogs.fact.name", order: 3 },
  first_name: { labelKey: "admin.auditLogs.fact.firstName", order: 4 },
  last_name: { labelKey: "admin.auditLogs.fact.lastName", order: 5 },
  phone: { labelKey: "admin.auditLogs.fact.phone", order: 6 },
  phone_number: { labelKey: "admin.auditLogs.fact.phone", order: 6 },
  role: { labelKey: "admin.auditLogs.fact.role", order: 7 },
  client_id: { labelKey: "admin.auditLogs.fact.clientIdLabel", order: 8 },
  client_name: { labelKey: "admin.auditLogs.fact.client", order: 9 },
  screening_id: { labelKey: "admin.auditLogs.fact.screeningIdLabel", order: 10 },
  practitioner_id: { labelKey: "admin.auditLogs.fact.practitionerId", order: 11 },
  score: { labelKey: "admin.auditLogs.fact.score", order: 12 },
  status: { labelKey: "admin.auditLogs.fact.status", order: 13 },
  message: { labelKey: "admin.auditLogs.fact.message", order: 14 },
  reason: { labelKey: "admin.auditLogs.fact.reason", order: 15 },
};

// Sensitive or noisy keys we never want to render to an admin's screen.
const SKIP_BODY_KEYS = new Set([
  "password",
  "password_confirmation",
  "current_password",
  "new_password",
  "token",
  "access_token",
  "refresh_token",
  "secret",
  "api_key",
  "params", // already shown via Record ID + Resource UUID
  "id",     // duplicate of resource_id
]);

export function extractRequestBodyFacts(details: object | null | undefined): RequestBodyFact[] {
  const body = bodyFromDetails(details);
  const out: Array<RequestBodyFact & { order: number }> = [];

  for (const [key, raw] of Object.entries(body)) {
    if (SKIP_BODY_KEYS.has(key)) continue;

    // Only show scalar values. Nested objects/arrays would dump too much
    // unstructured data into the slider — they're available in the raw JSON
    // toggle for anyone who needs them.
    let value: string | null = null;
    if (typeof raw === "string" && raw.trim()) value = raw.trim();
    else if (typeof raw === "number" || typeof raw === "boolean") value = String(raw);

    if (value === null) continue;

    const meta = BODY_FIELD_LABELS[key];
    if (meta) {
      out.push({ labelKey: meta.labelKey, value, order: meta.order });
    }
    // Unknown keys are intentionally skipped — surfacing every body field would
    // include framework noise (csrf tokens, accept headers etc.).
  }

  out.sort((a, b) => a.order - b.order);
  return out.map(({ labelKey, value }) => ({ labelKey, value }));
}

const RESOURCE_KEY_MAP: Record<string, { singular: string; plural: string }> = {
  client: {
    singular: "admin.auditLogs.resourceName.client",
    plural: "admin.auditLogs.resourceName.clientPlural",
  },
  screening: {
    singular: "admin.auditLogs.resourceName.screening",
    plural: "admin.auditLogs.resourceName.screeningPlural",
  },
  screening_report: {
    singular: "admin.auditLogs.resourceName.screeningReport",
    plural: "admin.auditLogs.resourceName.screeningReportPlural",
  },
  visibility_settings: {
    singular: "admin.auditLogs.resourceName.visibilitySettings",
    plural: "admin.auditLogs.resourceName.visibilitySettings",
  },
};

export function resourceNameKey(resource: string, plural: boolean): string {
  const entry = RESOURCE_KEY_MAP[resource];
  if (!entry) {
    return plural
      ? "admin.auditLogs.resourceName.genericPlural"
      : "admin.auditLogs.resourceName.generic";
  }
  return plural ? entry.plural : entry.singular;
}

export function humanizeAuditLog(log: AuditLogShape): AuditLogHumanized {
  // Backend casing isn't fully consistent — some events arrive as "Login"/"Auth"
  // and others as "login"/"auth". Normalize once so our switch and fallback both
  // hit a stable lowercase form.
  const action = (log.action || "").toLowerCase();
  const resourceType = (log.resource_type || "").toLowerCase();
  const id = log.resource_id != null ? String(log.resource_id) : "";
  const singular = resourceNameKey(resourceType, false);
  const plural = resourceNameKey(resourceType, true);
  const { id: clientId, name: clientName } = clientFromDetails(log.details);
  const body = bodyFromDetails(log.details);
  const hasClient = !!clientName;
  // Some events (login, signup) fire BEFORE the user is authenticated, so
  // actor_name is null. Fall back to body.email if backend included it, then
  // to a generic "Someone" so the headline never reads with a leading space.
  const actor =
    log.actor_name ||
    bodyString(body, "email") ||
    bodyString(body, "name") ||
    "Someone";
  const composite = `${action}/${resourceType}`;

  // Helper to produce the standard return shape with client info attached.
  const make = (
    headlineKey: string,
    headlineValues: Record<string, string | number>,
    tipKey: string | null,
    resourceKey: string | null,
  ): AuditLogHumanized => ({
    headlineKey,
    headlineValues,
    resourceNameKey: resourceKey,
    tipKey,
    clientName,
    clientId,
  });

  // ─── Auth — context comes from request_body, not from a client ──────────
  switch (composite) {
    case "register/auth": {
      const role = bodyString(body, "role");
      return make(
        role ? "admin.auditLogs.headline.registerWithRole" : "admin.auditLogs.headline.register",
        role ? { actor, role } : { actor },
        "admin.auditLogs.tip.register",
        null,
      );
    }
    case "login/auth":
      return make("admin.auditLogs.headline.login", { actor }, "admin.auditLogs.tip.login", null);
    case "logout/auth":
      return make("admin.auditLogs.headline.logout", { actor }, "admin.auditLogs.tip.logout", null);
    case "verify_email/auth":
      return make("admin.auditLogs.headline.verifyEmail", { actor }, "admin.auditLogs.tip.verifyEmail", null);
    case "password_reset/auth": {
      // Presence of `token` in the body means the reset link was clicked and a
      // new password was set. Its absence means it was just a request for the
      // link to be sent.
      const completed = !!body.token;
      return make(
        completed
          ? "admin.auditLogs.headline.passwordResetCompleted"
          : "admin.auditLogs.headline.passwordResetRequested",
        { actor },
        completed
          ? "admin.auditLogs.tip.passwordResetCompleted"
          : "admin.auditLogs.tip.passwordResetRequested",
        null,
      );
    }

    // ─── User management (admin) ──────────────────────────────────────────
    case "invite/user": {
      const name = bodyString(body, "name") || bodyString(body, "email");
      const role = bodyString(body, "role");
      if (name && role) {
        return make(
          "admin.auditLogs.headline.inviteUserWithDetails",
          { actor, name, role },
          "admin.auditLogs.tip.inviteUser",
          null,
        );
      }
      return make("admin.auditLogs.headline.inviteUser", { actor }, "admin.auditLogs.tip.inviteUser", null);
    }
    case "update/user":
      return make("admin.auditLogs.headline.updateUser", { actor }, "admin.auditLogs.tip.updateUser", null);
    case "delete/user":
      return make("admin.auditLogs.headline.deleteUser", { actor }, "admin.auditLogs.tip.deleteUser", null);

    // ─── Therapist (practice manager) ─────────────────────────────────────
    case "invite/therapist": {
      const name = bodyString(body, "name") || bodyString(body, "email");
      if (name) {
        return make(
          "admin.auditLogs.headline.inviteTherapistWithName",
          { actor, name },
          "admin.auditLogs.tip.inviteTherapist",
          null,
        );
      }
      return make("admin.auditLogs.headline.inviteTherapist", { actor }, "admin.auditLogs.tip.inviteTherapist", null);
    }
    case "update/therapist":
      return make("admin.auditLogs.headline.updateTherapist", { actor }, "admin.auditLogs.tip.updateTherapist", null);
    case "delete/therapist":
      return make("admin.auditLogs.headline.deleteTherapist", { actor }, "admin.auditLogs.tip.deleteTherapist", null);
    case "accept_invite/therapist":
      return make("admin.auditLogs.headline.acceptInviteTherapist", {}, "admin.auditLogs.tip.acceptInviteTherapist", null);

    // ─── Client — resource is the client itself ───────────────────────────
    case "create/client": {
      const newName = bodyString(body, "name");
      if (newName) {
        return make(
          "admin.auditLogs.headline.createClientNamed",
          { actor, name: newName },
          "admin.auditLogs.tip.create",
          null,
        );
      }
      return make("admin.auditLogs.headline.create", { actor }, "admin.auditLogs.tip.create", singular);
    }
    case "view/client":
      // Prefer the friendlier "viewed client {name}" when backend supplied it.
      if (hasClient) {
        return make(
          "admin.auditLogs.headline.viewClientNamed",
          { actor, client: clientName! },
          "admin.auditLogs.tip.view",
          null,
        );
      }
      return make(
        id ? "admin.auditLogs.headline.view" : "admin.auditLogs.headline.viewNoId",
        { actor, id },
        "admin.auditLogs.tip.view",
        singular,
      );
    case "update/client":
      if (hasClient) {
        return make(
          "admin.auditLogs.headline.updateClientNamed",
          { actor, client: clientName! },
          "admin.auditLogs.tip.update",
          null,
        );
      }
      return make(
        id ? "admin.auditLogs.headline.update" : "admin.auditLogs.headline.updateNoId",
        { actor, id },
        "admin.auditLogs.tip.update",
        singular,
      );
    case "delete/client":
      if (hasClient) {
        return make(
          "admin.auditLogs.headline.deleteClientNamed",
          { actor, client: clientName! },
          "admin.auditLogs.tip.delete",
          null,
        );
      }
      return make(
        id ? "admin.auditLogs.headline.delete" : "admin.auditLogs.headline.deleteNoId",
        { actor, id },
        "admin.auditLogs.tip.delete",
        singular,
      );

    // ─── Screening — resource carries the screening id, client comes from context
    case "view/screening":
      return make(
        hasClient
          ? "admin.auditLogs.headline.viewScreeningWithClient"
          : "admin.auditLogs.headline.viewScreening",
        hasClient ? { actor, client: clientName! } : { actor },
        "admin.auditLogs.tip.view",
        null,
      );

    // ─── Screening report — view (share/download already handled below) ───
    case "view/screening_report":
      return make(
        hasClient
          ? "admin.auditLogs.headline.viewReportWithClient"
          : "admin.auditLogs.headline.viewReport",
        hasClient ? { actor, client: clientName! } : { actor },
        "admin.auditLogs.tip.view",
        null,
      );

    // ─── Visibility settings — view (change handled below) ────────────────
    case "view/visibility_settings":
      return make(
        hasClient
          ? "admin.auditLogs.headline.viewVisibilityWithClient"
          : "admin.auditLogs.headline.viewVisibility",
        hasClient ? { actor, client: clientName! } : { actor },
        "admin.auditLogs.tip.view",
        null,
      );
  }

  // ─── Action-only fallbacks for resources we haven't specialized ─────────
  switch (action) {
    case "view_list":
      return make("admin.auditLogs.headline.viewList", { actor }, "admin.auditLogs.tip.viewList", plural);
    case "view":
      return make(
        id ? "admin.auditLogs.headline.view" : "admin.auditLogs.headline.viewNoId",
        { actor, id },
        "admin.auditLogs.tip.view",
        singular,
      );
    case "create":
      return make("admin.auditLogs.headline.create", { actor }, "admin.auditLogs.tip.create", singular);
    case "update":
      return make(
        id ? "admin.auditLogs.headline.update" : "admin.auditLogs.headline.updateNoId",
        { actor, id },
        "admin.auditLogs.tip.update",
        singular,
      );
    case "delete":
      return make(
        id ? "admin.auditLogs.headline.delete" : "admin.auditLogs.headline.deleteNoId",
        { actor, id },
        "admin.auditLogs.tip.delete",
        singular,
      );
    case "start_screening":
      return make(
        hasClient
          ? "admin.auditLogs.headline.startScreeningWithClient"
          : "admin.auditLogs.headline.startScreening",
        hasClient ? { actor, client: clientName! } : { actor },
        "admin.auditLogs.tip.startScreening",
        null,
      );
    case "submit_screening":
      return make(
        hasClient
          ? "admin.auditLogs.headline.submitScreeningWithClient"
          : "admin.auditLogs.headline.submitScreening",
        hasClient ? { actor, client: clientName! } : { actor },
        "admin.auditLogs.tip.submitScreening",
        null,
      );
    case "share_report":
      return make(
        hasClient
          ? "admin.auditLogs.headline.shareReportWithClient"
          : "admin.auditLogs.headline.shareReport",
        hasClient ? { actor, client: clientName! } : { actor },
        "admin.auditLogs.tip.shareReport",
        null,
      );
    case "download_report":
      return make(
        hasClient
          ? "admin.auditLogs.headline.downloadReportWithClient"
          : "admin.auditLogs.headline.downloadReport",
        hasClient ? { actor, client: clientName! } : { actor },
        "admin.auditLogs.tip.downloadReport",
        null,
      );
    case "change_visibility":
      return make(
        hasClient
          ? "admin.auditLogs.headline.changeVisibilityWithClient"
          : "admin.auditLogs.headline.changeVisibility",
        hasClient ? { actor, client: clientName! } : { actor },
        "admin.auditLogs.tip.changeVisibility",
        null,
      );
    default:
      return make(
        "admin.auditLogs.headline.generic",
        { actor, action },
        null,
        singular,
      );
  }
}
