import { AsyncLocalStorage } from 'node:async_hooks';
import { isIP } from 'node:net';
import { UAParser } from 'ua-parser-js';
import {
  BadRequestException,
  HttpException,
  Injectable,
  Logger,
} from '@nestjs/common';
import { Prisma } from '@/generated/prisma/client';
import { ActivityLogsRepository } from './activity-logs.repository';
import { CaslAbilityFactory } from '@/casl/casl-ability.factory';
import {
  ExcelExportService,
  toExcelPlainText,
} from '@/common/excel/excel-export.service';
import type { Request } from 'express';
import {
  ActivityAction,
  ActivityLogOptions,
  ActivityOutcome,
  StatusChangeSource,
} from '@/common/decorators/activity-log.decorator';
import { ActivityLogQueryDto } from './dto/activity-log-query.dto';

export interface ActivityActor {
  actorId: number;
  actorName: string;
  actorRoles: string[];
}

export interface ActivityEntry {
  module: string;
  action: ActivityAction;
  recordReference: string;
  // How the action arrived (safe route and IDs, never a raw URL or body).
  method?: string;
  endpoint?: string;
  // The login the action came from, so actions can be grouped by session.
  sessionId?: string;
  ipAddress?: string;
  userAgent?: string;
  // Only whitelisted fields, e.g. { status: { from, to } }.
  changes?: ActivityChanges;
  // Left out for a successful action. Denied and failed attempts keep the
  // HTTP status and the reason, never the request body.
  outcome?: ActivityOutcome;
  statusCode?: number;
  // Why it was refused or failed; see describeError.
  errorMessage?: string;
  // The record's title or name when the action happened.
  recordLabel?: string;
}

// Only IDs are accepted from dynamic values. Names, tokens, and arbitrary text never enter references.
export function safeId(value: unknown): string | undefined {
  if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0)
    return String(value);
  if (
    typeof value === 'string' &&
    (/^\d{1,10}$/.test(value) || /^[0-9a-f-]{36}$/i.test(value))
  )
    return value;
  return undefined;
}

// The parts of a request the log may keep: the HTTP method, the route with
// safe ids filled in, and the reference template with safe ids filled in.
// Shared by the interceptor (successful and failed actions) and the guards
// (denied actions), so every row describes a request the same way.
export function describeRequest(
  options: ActivityLogOptions,
  request: Request,
  actorId: number,
) {
  const params: Record<string, unknown> = request.params ?? {};
  const routePath: unknown = (request as { route?: { path?: unknown } }).route
    ?.path;
  const endpoint =
    typeof routePath === 'string'
      ? routePath
          .replace(
            /:([A-Za-z]+)/g,
            (placeholder, key: string) => safeId(params[key]) ?? placeholder,
          )
          .slice(0, 255)
      : undefined;
  const reference = options.reference.replace(
    /:([A-Za-z]+)/g,
    (_, key: string) =>
      safeId(params[key]) ??
      // A whitelisted id may come from the query string instead.
      (key === options.queryIdField
        ? safeId((request.query as Record<string, unknown> | undefined)?.[key])
        : undefined) ??
      (key === 'actorId' ? String(actorId) : '?'),
  );
  return {
    method: typeof request.method === 'string' ? request.method : undefined,
    endpoint,
    reference,
  };
}

// Why an action was refused or failed, short enough for one column.
// An HTTP error's message is written by this application, so it is quoted as
// it is (validation messages name the fields at fault). Anything else -- a
// database or runtime error -- can carry record contents in its text, so only
// the kind of error is kept.
export function describeError(error: unknown): string | undefined {
  if (error instanceof HttpException) {
    const payload: unknown = error.getResponse();
    const message: unknown =
      typeof payload === 'string'
        ? payload
        : (payload as { message?: unknown })?.message;
    const text = Array.isArray(message)
      ? message.filter((item) => typeof item === 'string').join('; ')
      : typeof message === 'string'
        ? message
        : error.message;
    return typeof text === 'string' && text ? text.slice(0, 255) : undefined;
  }

  return error instanceof Error
    ? error.constructor.name.slice(0, 255)
    : undefined;
}

// The login id carried by the token, when the request has one.
export function requestSessionId(request: Request): string | undefined {
  const session = (request as { user?: { sessionId?: unknown } }).user
    ?.sessionId;
  return typeof session === 'string' && session
    ? session.slice(0, 36)
    : undefined;
}

// The first number in a reference names the record ("issues/12/comments/3"
// -> 12), which is how one record's history is listed.
export function referenceRecordId(reference: string): number | undefined {
  const match = /\d+/.exec(reference);
  const id = match ? Number(match[0]) : NaN;
  return Number.isSafeInteger(id) && id > 0 ? id : undefined;
}

// Which snapshot fields may serve as the record's label, most specific first.
const RECORD_LABEL_FIELDS = ['title', 'name', 'translationKey'];
const RECORD_LABEL_NESTED = [
  /\.meeting\.title$/, // meeting summaries are named after their meeting
  /^issue\.title$/,
  /^ministry\.name$/,
];

// A short human name for the record, taken from the fields already read for
// the change comparison. Content fields (descriptions, comments) never qualify.
export function pickRecordLabel(
  snapshot: ActivitySnapshot | undefined,
): string | undefined {
  if (!snapshot) return undefined;
  const value = (key: string) => {
    const field = snapshot[key];
    return field && !field.content && field.value?.trim()
      ? field.value.trim().slice(0, 255)
      : undefined;
  };
  for (const field of RECORD_LABEL_FIELDS) {
    const label = value(field);
    if (label) return label;
  }
  for (const pattern of RECORD_LABEL_NESTED) {
    const key = Object.keys(snapshot)
      .sort()
      .find((candidate) => pattern.test(candidate));
    const label = key ? value(key) : undefined;
    if (label) return label;
  }
  return undefined;
}

// Rows in one Excel export; beyond this, narrow the filters.
export const ACTIVITY_EXPORT_LIMIT = 5000;
const CAMBODIA_OFFSET_MS = 7 * 60 * 60 * 1000;

// Cambodia has a fixed UTC+7 offset. Use an exclusive next-day upper bound.
export function activityDateBoundary(value: string, end = false): Date {
  const utc = new Date(`${value}T00:00:00.000Z`);
  if (
    !Number.isFinite(utc.getTime()) ||
    utc.toISOString().slice(0, 10) !== value
  ) {
    throw new BadRequestException('Invalid calendar date');
  }
  return new Date(utc.getTime() - 7 * 60 * 60 * 1000 + (end ? 86400000 : 0));
}

// A filter can arrive as one value or a list (the filter panel allows several
// choices). One value matches exactly; several match any of them; none means
// "no filter", which Prisma skips when the value is undefined.
function matchAny<T>(value: T | T[] | undefined) {
  const values =
    value === undefined ? [] : Array.isArray(value) ? value : [value];
  if (values.length === 0) return undefined;
  return values.length === 1 ? values[0] : { in: values };
}

@Injectable()
export class ActivityLogsService {
  private readonly logger = new Logger(ActivityLogsService.name);

  constructor(
    private readonly repository: ActivityLogsRepository,
    private readonly casl: CaslAbilityFactory,
    private readonly excel: ExcelExportService,
  ) {}

  // Store a short browser/OS description, not the raw header.
  requestDetails(ip: string | undefined, userAgent: string | undefined) {
    let browserDescription: string | undefined;
    try {
      if (userAgent) {
        const parsed = new UAParser(userAgent.slice(0, 1024)).getResult();
        const browser = [parsed.browser.name, parsed.browser.major]
          .filter(Boolean)
          .join(' ');
        browserDescription =
          [browser, parsed.os.name].filter(Boolean).join(' · ').slice(0, 255) ||
          undefined;
      }
    } catch {
      // Missing or unrecognized headers must not affect the action.
    }
    return {
      ipAddress: ip && isIP(ip) ? ip : undefined,
      userAgent: browserDescription,
    };
  }

  async readPrimaryAgency(id: number): Promise<string | null | undefined> {
    if (!Number.isSafeInteger(id) || id <= 0) return undefined;
    try {
      const issue = await this.repository.findIssuePrimaryAgency(id);
      if (!issue) return undefined;
      return issue.governmentAgencies[0]?.stakeholder.name ?? null;
    } catch {
      this.logger.error('Activity primary agency lookup failed');
      return undefined;
    }
  }

  async captureSnapshot(
    options: ActivityLogOptions,
    id: number,
  ): Promise<ActivitySnapshot | undefined> {
    if (!options.snapshot) return undefined;
    if (!Number.isSafeInteger(id) || id <= 0) return undefined;
    try {
      const row = await this.repository.findSnapshot(
        options.snapshot.source,
        id,
      );
      return row ? flattenActivitySnapshot(row) : {};
    } catch {
      this.logger.error(`Activity snapshot failed: ${options.snapshot.source}`);
      return undefined;
    }
  }

  async captureTransactionSnapshot(
    options: ActivityLogOptions,
    id: number,
    client: Prisma.TransactionClient,
  ): Promise<ActivitySnapshot | undefined> {
    if (!options.snapshot || !Number.isSafeInteger(id) || id <= 0)
      return undefined;
    await client.$executeRawUnsafe('SAVEPOINT activity_snapshot');
    try {
      const row = await this.repository.findSnapshot(
        options.snapshot.source,
        id,
        client,
      );
      await client.$executeRawUnsafe('RELEASE SAVEPOINT activity_snapshot');
      return row ? flattenActivitySnapshot(row) : {};
    } catch {
      await client.$executeRawUnsafe('ROLLBACK TO SAVEPOINT activity_snapshot');
      await client.$executeRawUnsafe('RELEASE SAVEPOINT activity_snapshot');
      this.logger.error(
        `Activity transaction snapshot failed: ${options.snapshot.source}`,
      );
      return undefined;
    }
  }

  // Capture BEFORE a change so renaming a user or removing their role cannot rewrite history.
  async captureActor(actorId: number): Promise<ActivityActor | null> {
    try {
      const [user, grants] = await Promise.all([
        this.repository.findActor(actorId),
        this.casl.getGrantsForUser(actorId),
      ]);
      return {
        actorId,
        actorName: user?.name?.trim() || `User #${actorId}`,
        actorRoles: grants.roles,
      };
    } catch {
      this.logger.error(`Activity actor lookup failed for user ${actorId}`);
      return null;
    }
  }

  async record(
    actor: ActivityActor | null,
    entry: ActivityEntry,
  ): Promise<void> {
    if (!actor) return;
    const outcome = entry.outcome ?? 'SUCCESS';
    try {
      await this.repository.create({
        actorId: actor.actorId,
        actorName: actor.actorName,
        actorRoles: actor.actorRoles,
        module: entry.module,
        action: entry.action,
        recordReference: entry.recordReference,
        recordId: referenceRecordId(entry.recordReference),
        recordLabel: entry.recordLabel,
        method: entry.method,
        endpoint: entry.endpoint,
        sessionId: entry.sessionId,
        ipAddress: entry.ipAddress,
        userAgent: entry.userAgent,
        outcome,
        statusCode: entry.statusCode,
        errorMessage: entry.errorMessage,
        changes: entry.changes ?? {
          _outcome: {
            from: null,
            to: outcome === 'SUCCESS' ? entry.action : outcome,
            kind: 'outcome',
          },
        },
      });
    } catch {
      // Do not print request data or database errors that might contain sensitive values.
      this.logger.error(
        `Activity log write failed: ${entry.module}/${entry.action}, actor ${actor.actorId}`,
      );
    }
  }

  async recordForUser(actorId: number, entry: ActivityEntry): Promise<void> {
    await this.record(await this.captureActor(actorId), entry);
  }

  // A logged-in user tried a logged action and a guard refused it. Guards run
  // before the interceptor, so they call this themselves just before throwing.
  async recordDenied(
    options: ActivityLogOptions,
    request: Request,
    actorId: number,
    denial: HttpException,
  ): Promise<void> {
    const { method, endpoint, reference } = describeRequest(
      options,
      request,
      actorId,
    );
    await this.recordForUser(actorId, {
      module: options.module,
      action: options.action,
      recordReference: reference,
      ...this.requestDetails(request.ip, request.headers?.['user-agent']),
      method,
      endpoint,
      sessionId: requestSessionId(request),
      outcome: 'DENIED',
      statusCode: denial.getStatus(),
      errorMessage: describeError(denial),
    });
  }

  // The filters shared by the list and the Excel export.
  private buildWhere(query: ActivityLogQueryDto): Prisma.ActivityLogWhereInput {
    const from = query.from ? activityDateBoundary(query.from) : undefined;
    const to = query.to ? activityDateBoundary(query.to, true) : undefined;
    if (from && to && from >= to)
      throw new BadRequestException('From date must not be after to date');
    const search = query.search?.trim();
    return {
      actorId: matchAny(query.actorId),
      module: matchAny(query.module),
      action: matchAny(query.action),
      ...(query.role?.length ? { actorRoles: { hasSome: query.role } } : {}),
      ...(query.reference ? { recordReference: query.reference } : {}),
      outcome: matchAny(query.outcome),
      recordId: query.recordId,
      ...(from || to ? { createdAt: { gte: from, lt: to } } : {}),
      ...(search
        ? {
            OR: [
              { actorName: { contains: search, mode: 'insensitive' } },
              { recordReference: { contains: search, mode: 'insensitive' } },
            ],
          }
        : {}),
    };
  }

  // The chosen column first; id keeps rows with equal values in a stable
  // order, so paging never repeats or skips a row.
  private buildOrderBy(
    query: ActivityLogQueryDto,
  ): Prisma.ActivityLogOrderByWithRelationInput[] {
    return query.sortBy
      ? [{ [query.sortBy]: query.sortOrder ?? 'asc' }, { id: 'desc' }]
      : [{ createdAt: 'desc' }, { id: 'desc' }];
  }

  // Current photo and organisation of each user on a page. The log keeps no
  // link to users (history survives renames and deletions), so these are looked
  // up for the listed users only; a deleted user simply has neither.
  private async describeActors(actorIds: number[]) {
    if (actorIds.length === 0) return new Map<number, ActorDetails>();
    const users = await this.repository.findActorDetails(actorIds);
    return new Map<number, ActorDetails>(
      users.map((user) => {
        const organization = user.stakeholders?.[0]?.stakeholder;
        return [
          user.id,
          {
            avatar: user.avatar ?? null,
            organization: organization
              ? {
                  name: organization.name,
                  description: organization.description
                    ? toExcelPlainText(organization.description)
                    : null,
                }
              : null,
          },
        ];
      }),
    );
  }

  async findAll(query: ActivityLogQueryDto) {
    const where = this.buildWhere(query);
    const [data, total] = await this.repository.findPage(
      where,
      this.buildOrderBy(query),
      query.page,
      query.pageSize,
    );
    const actors = await this.describeActors([
      ...new Set(data.map((row) => row.actorId)),
    ]);

    return {
      data: data.map((row) => ({
        ...row,
        actorAvatar: actors.get(row.actorId)?.avatar ?? null,
        actorOrganization: actors.get(row.actorId)?.organization ?? null,
      })),
      meta: {
        page: query.page,
        limit: query.pageSize,
        total,
        totalPages: Math.ceil(total / query.pageSize),
      },
    };
  }

  // The filtered log as an Excel file (newest first, up to ACTIVITY_EXPORT_LIMIT rows).
  async exportWorkbook(query: ActivityLogQueryDto) {
    const rows = await this.repository.findForExport(
      this.buildWhere(query),
      this.buildOrderBy(query),
      ACTIVITY_EXPORT_LIMIT,
    );
    const buffer = await this.excel.buildWorkbookBuffer({
      sheetName: 'Activity log',
      columns: [
        { header: 'Date / time (UTC+7)', key: 'date', width: 22 },
        { header: 'User', key: 'user', width: 26 },
        { header: 'User ID', key: 'userId', width: 10 },
        { header: 'Roles', key: 'roles', width: 22 },
        { header: 'Module', key: 'module', width: 22 },
        { header: 'Action', key: 'action', width: 16 },
        { header: 'Outcome', key: 'outcome', width: 10 },
        { header: 'Status code', key: 'statusCode', width: 8 },
        { header: 'Error', key: 'errorMessage', width: 40 },
        { header: 'Affected record', key: 'record', width: 40 },
        { header: 'Record name', key: 'recordLabel', width: 32 },
        { header: 'Changes', key: 'changes', width: 32 },
        { header: 'Method', key: 'method', width: 10 },
        { header: 'Endpoint', key: 'endpoint', width: 44 },
        { header: 'Session ID', key: 'sessionId', width: 38 },
      ],
      rows: rows.map((row) => ({
        date: new Date(row.createdAt.getTime() + CAMBODIA_OFFSET_MS)
          .toISOString()
          .replace('T', ' ')
          .slice(0, 19),
        user: row.actorName,
        userId: row.actorId,
        roles: row.actorRoles.join(', '),
        module: row.module,
        action: row.action,
        outcome: row.outcome,
        statusCode: row.statusCode,
        errorMessage: row.errorMessage,
        record: row.recordReference,
        recordLabel: row.recordLabel,
        changes: describeChanges(row.changes),
        method: row.method,
        endpoint: row.endpoint,
        sessionId: row.sessionId,
      })),
    });
    const today = new Date(Date.now() + CAMBODIA_OFFSET_MS)
      .toISOString()
      .slice(0, 10);
    return { buffer, filename: `activity-log-${today}.xlsx` };
  }

  // The current status of one record, for "from -> to". Only the whitelisted
  // record types; any failure just means no status change is recorded.
  async readStatus(
    source: StatusChangeSource,
    params: Record<string, unknown>,
    idParam: 'id' | 'issueId' = 'id',
  ): Promise<string | null> {
    try {
      if (source === 'meetingSummary') {
        const id = Number(params[idParam]);
        if (!Number.isSafeInteger(id) || id <= 0) return null;
        const row = await this.repository.findMeetingSummaryStatus(id);
        return row?.meetingSummaryStatus?.name ?? null;
      }
      if (source === 'issue') {
        const id = Number(params[idParam]);
        if (!Number.isSafeInteger(id) || id <= 0) return null;
        const row = await this.repository.findIssueStatus(id);
        return row?.issueStatus?.name ?? null;
      }
      const progressReportId = Number(params.progressReportId);
      const ministryId = Number(params.ministryId);
      if (
        !Number.isSafeInteger(progressReportId) ||
        !Number.isSafeInteger(ministryId)
      )
        return null;
      const row = await this.repository.findProgressReportMinistryStatus(
        progressReportId,
        ministryId,
      );
      return row?.status ?? null;
    } catch {
      this.logger.error(`Activity status lookup failed for ${source}`);
      return null;
    }
  }

  async filterOptions() {
    const [modules, actions, actors, roleSets] =
      await this.repository.findFilterOptions();
    return {
      modules: modules.map((row) => row.module),
      actions: actions.map((row) => row.action),
      roles: [
        ...new Set((roleSets ?? []).flatMap((row) => row.actorRoles)),
      ].sort(),
      actors: actors.sort((a, b) => a.actorName.localeCompare(b.actorName)),
    };
  }
}

type ActorDetails = {
  avatar: string | null;
  organization: { name: string; description: string | null } | null;
};

// "status: Draft -> Shared" for the Excel sheet.
function describeChanges(changes: unknown): string {
  if (!changes || typeof changes !== 'object') return '';
  return Object.entries(
    changes as Record<string, { from?: unknown; to?: unknown; kind?: string }>,
  )
    .map(([field, value]) => {
      if (field === '_outcome')
        return `Outcome: ${typeof value.to === 'string' ? value.to : ''}`;
      if (value.kind && ['added', 'removed', 'changed'].includes(value.kind))
        return `${field}: ${value.kind}`;
      // Only text values are shown; anything else reads as "-".
      const from = typeof value?.from === 'string' ? value.from : '-';
      const to = typeof value?.to === 'string' ? value.to : '-';
      return `${field}: ${from} -> ${to}`;
    })
    .join('; ');
}

export type ActivityChange = {
  from: string | null;
  to: string | null;
  kind?: 'value' | 'added' | 'removed' | 'changed' | 'outcome';
};
export type ActivityChanges = Record<string, ActivityChange>;
type SnapshotValue = { value: string | null; content: boolean };
export type ActivitySnapshot = Record<string, SnapshotValue>;

// These values may be compared in memory, but must never be copied into a log.
const CONTENT_FIELDS = new Set([
  'description',
  'recommendation',
  'attachment',
  'attachement',
  'document',
  'documentReference',
  'meetingRequestLetter',
  'draftSemesterReport',
  'finalSemesterReport',
  'comment',
  'feedback',
  'sendBackReason',
  'message',
  'decision',
  'rgcDecision',
  'nextStep',
  'next_step',
  'remark',
  'indicators',
  'progressSolution',
  'implementationChallenges',
  'requests',
  'sourceOfVerification',
  'linkToVerificationSource',
  'verificationSource',
  'verificationLink',
  'englishText',
  'khmerText',
  'avatar',
  'logo',
  'systemLogo',
  'gmailAddress',
]);

// Only call this with the repository's explicit selections, never request bodies.
export function flattenActivitySnapshot(value: unknown): ActivitySnapshot {
  const result: ActivitySnapshot = {};
  function visit(item: unknown, path: string) {
    const field = path.split('.').at(-1) ?? '';
    if (CONTENT_FIELDS.has(field)) {
      result[path] = {
        value: item == null || item === '' ? null : JSON.stringify(item),
        content: true,
      };
    } else if (item instanceof Date) {
      result[path] = { value: item.toISOString(), content: false };
    } else if (Array.isArray(item)) {
      for (const entry of item) {
        const row = entry as Record<string, unknown>;
        const identity =
          (typeof row.id === 'number' || typeof row.id === 'string'
            ? row.id
            : undefined) ??
          Object.values(row)
            .filter((v) => v && typeof v === 'object' && 'id' in v)
            .map((v) => (v as { id: number }).id)
            .join('-');
        visit(entry, `${path}.#${identity}`);
      }
    } else if (item && typeof item === 'object') {
      if ('deletedAt' in item && item.deletedAt != null) return;
      for (const [key, child] of Object.entries(item)) {
        visit(child, path ? `${path}.${key}` : key);
      }
    } else if (path) {
      result[path] = {
        value:
          typeof item === 'string' ||
          typeof item === 'number' ||
          typeof item === 'boolean'
            ? String(item)
            : null,
        content: false,
      };
    }
  }
  visit(value, '');
  return result;
}

export function compareActivitySnapshots(
  before: ActivitySnapshot | undefined,
  after: ActivitySnapshot | undefined,
  action: ActivityAction,
): ActivityChanges {
  if (!before || !after)
    return { _outcome: { from: null, to: 'UNAVAILABLE', kind: 'outcome' } };
  const changes: ActivityChanges = {};
  for (const key of [
    ...new Set([...Object.keys(before), ...Object.keys(after)]),
  ].sort()) {
    const oldValue = before[key]?.value ?? null;
    const newValue = after[key]?.value ?? null;
    if (oldValue === newValue) continue;
    if (before[key]?.content || after[key]?.content) {
      changes[key] = {
        from: null,
        to: null,
        kind:
          oldValue === null
            ? 'added'
            : newValue === null
              ? 'removed'
              : 'changed',
      };
    } else {
      changes[key] = { from: oldValue, to: newValue, kind: 'value' };
    }
  }
  changes._outcome = {
    from: null,
    to:
      Object.keys(changes).length || action !== 'UPDATE' ? action : 'UNCHANGED',
    kind: 'outcome',
  };
  return changes;
}

// Request-local state lets existing business transactions supply their own snapshots.
// The interceptor remains the only writer and writes after the transaction commits.
export interface ActivityTransactionContext {
  read: (
    client: Prisma.TransactionClient,
    id: number,
  ) => Promise<ActivitySnapshot | undefined>;
  id: number;
  resultKey?: string;
  before?: ActivitySnapshot;
  after?: ActivitySnapshot;
  captured: boolean;
}
export const activityTransactionContext =
  new AsyncLocalStorage<ActivityTransactionContext>();

export async function withActivityTransactionSnapshot<T>(
  client: Prisma.TransactionClient,
  operation: () => Promise<T>,
): Promise<T> {
  const context = activityTransactionContext.getStore();
  if (!context) return operation();
  const read = async (id: number) => {
    try {
      return await context.read(client, id);
    } catch {
      new Logger('ActivityLogsService').error(
        'Activity transaction snapshot unavailable',
      );
      return undefined;
    }
  };
  const before = context.id > 0 ? await read(context.id) : {};
  const result = await operation();
  let data: unknown = result;
  for (
    let depth = 0;
    depth < 3 && data && typeof data === 'object' && 'data' in data;
    depth++
  )
    data = data.data;
  if (context.resultKey && data && typeof data === 'object')
    data = (data as Record<string, unknown>)[context.resultKey] ?? data;
  const resultId =
    data && typeof data === 'object' && 'id' in data ? Number(data.id) : NaN;
  const id = context.id > 0 ? context.id : resultId;
  if (!Number.isSafeInteger(id) || id <= 0) return result;
  const after = await read(id);
  if (!context.captured) context.before = before;
  context.after = after;
  context.captured = true;
  return result;
}
