/**
 * Report runs — append-only history of every generated report. The render
 * queue inserts a `pending` row before kicking off, then flips status to
 * `rendering` / `complete` / `failed` as it progresses; clients poll the
 * list or read individual rows for status.
 *
 * Multi-tenant safe: every query carries `port_id = ctx.portId`. Snapshots
 * the config used so a re-run replays identically even if the source
 * template was edited or archived.
 *
 * Pairs with `report-templates.service.ts` (template CRUD) and
 * `report-schedules.service.ts` (cadence-driven auto-runs). The BullMQ
 * render worker (P3) consumes the `pending` rows produced here.
 */

import { and, desc, eq } from 'drizzle-orm';

import { db } from '@/lib/db';
import {
  reportRuns,
  reportTemplates,
  type ReportRun,
  type NewReportRun,
} from '@/lib/db/schema/reports';
import { userPortRoles, userProfiles } from '@/lib/db/schema/users';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
import type {
  CreateReportRunInput,
  ListReportRunsInput,
  ReportRunStatus,
} from '@/lib/validators/reports';

export interface ListReportRunsResult {
  data: ReportRun[];
  total: number;
  hasMore: boolean;
}

/**
 * True when the user may act on behalf of `portId`: either a platform
 * super-admin, or the holder of a `userPortRoles` membership row for that
 * port. Mirrors the Socket.IO `userCanAccessPort` guard — the canonical
 * "is this user a member of this tenant" check. Used to gate the
 * cover-brand override so a rep can't stamp a foreign port's logo +
 * name onto a report (audit finding H11). Empty/falsy userId → false.
 */
export async function userCanAccessPort(
  userId: string | null | undefined,
  portId: string,
): Promise<boolean> {
  if (!userId) return false;
  const profile = await db.query.userProfiles.findFirst({
    where: eq(userProfiles.userId, userId),
    columns: { isSuperAdmin: true },
  });
  if (profile?.isSuperAdmin) return true;
  const role = await db.query.userPortRoles.findFirst({
    where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)),
    columns: { id: true },
  });
  return Boolean(role);
}

export async function listReportRuns(
  portId: string,
  query: ListReportRunsInput,
): Promise<ListReportRunsResult> {
  const conditions = [eq(reportRuns.portId, portId)];
  if (query.kind) conditions.push(eq(reportRuns.kind, query.kind));
  if (query.status) conditions.push(eq(reportRuns.status, query.status));
  if (query.templateId) conditions.push(eq(reportRuns.templateId, query.templateId));

  const where = and(...conditions);
  const offset = (query.page - 1) * query.pageSize;

  const [rows, total] = await Promise.all([
    db
      .select()
      .from(reportRuns)
      .where(where)
      .orderBy(desc(reportRuns.createdAt))
      .limit(query.pageSize)
      .offset(offset),
    db.$count(reportRuns, where),
  ]);

  return {
    data: rows,
    total: Number(total),
    hasMore: offset + rows.length < Number(total),
  };
}

export async function getReportRun(id: string, portId: string): Promise<ReportRun> {
  const row = await db.query.reportRuns.findFirst({
    where: and(eq(reportRuns.id, id), eq(reportRuns.portId, portId)),
  });
  if (!row) throw new NotFoundError('Report run');
  return row;
}

export interface CreateReportRunOptions {
  portId: string;
  /** When set, the run is anchored to this template so re-runs / re-emails
   *  can resolve back to a named source. Validated to belong to the same
   *  port so a foreign-port template id can't be smuggled in. */
  triggeredBy: 'user' | 'schedule';
  triggeredByUserId?: string;
  scheduleId?: string;
  meta: AuditMeta;
}

export async function createReportRun(
  input: CreateReportRunInput,
  options: CreateReportRunOptions,
): Promise<ReportRun> {
  // Cross-validate that the config's discriminator matches the outer kind
  // (same guard as `/api/v1/reports/templates` POST). Without this, a rep
  // could queue a clients-kind run with a dashboard config payload and
  // confuse the render path at use time.
  const configKind = (input.config as { kind?: unknown }).kind;
  if (typeof configKind === 'string' && configKind !== input.kind) {
    throw new ValidationError(`config.kind must equal "${input.kind}"`);
  }

  // H11: the cover-brand override (`config.coverBrandPortId`) swaps the
  // cover-page logo + port name to another port's brand kit. All report
  // DATA still comes from `options.portId`, so the override's only effect
  // is rendering a foreign port's branding — a cross-tenant leak vector.
  //
  // For USER-triggered runs there is an accountable user, so gate the
  // override to ports that user belongs to (super-admins exempt) and reject
  // before the row is even queued. For SCHEDULE-triggered runs there is no
  // human actor to attribute access to, so we don't reject here (that would
  // silently kill a whole scheduled report); the renderer's defense-in-depth
  // check strips an unauthorized override and falls back to the source
  // port's branding instead.
  const coverBrandPortId = (input.config as { coverBrandPortId?: unknown }).coverBrandPortId;
  if (
    options.triggeredBy === 'user' &&
    typeof coverBrandPortId === 'string' &&
    coverBrandPortId.length > 0 &&
    coverBrandPortId !== options.portId
  ) {
    const allowed = await userCanAccessPort(options.triggeredByUserId, coverBrandPortId);
    if (!allowed) {
      throw new ForbiddenError('You do not have access to the port selected for cover branding');
    }
  }

  // Verify template ownership when provided. Belt-and-braces: the route
  // already gates by port via withAuth, but a stale template id from a
  // cached UI would otherwise produce an opaque FK constraint error.
  if (input.templateId) {
    const tmpl = await db.query.reportTemplates.findFirst({
      where: and(
        eq(reportTemplates.id, input.templateId),
        eq(reportTemplates.portId, options.portId),
      ),
      columns: { id: true, kind: true },
    });
    if (!tmpl) throw new NotFoundError('Report template');
    if (tmpl.kind !== input.kind) {
      throw new ValidationError(
        `Template kind "${tmpl.kind}" does not match requested kind "${input.kind}"`,
      );
    }
  }

  const values: NewReportRun = {
    portId: options.portId,
    templateId: input.templateId ?? null,
    scheduleId: options.scheduleId ?? null,
    kind: input.kind,
    config: input.config,
    outputFormat: input.outputFormat,
    status: 'pending',
    triggeredBy: options.triggeredBy,
    triggeredByUserId: options.triggeredByUserId ?? null,
    emailedTo: input.emailTo ?? null,
  };

  const [row] = await db.insert(reportRuns).values(values).returning();
  if (!row) throw new Error('createReportRun: insert returned no row');

  void createAuditLog({
    portId: options.portId,
    userId: options.meta.userId,
    action: 'create',
    entityType: 'report_run',
    entityId: row.id,
    newValue: { kind: row.kind, outputFormat: row.outputFormat, status: row.status },
    metadata: {
      triggeredBy: row.triggeredBy,
      templateId: row.templateId,
      scheduleId: row.scheduleId,
    },
    ipAddress: options.meta.ipAddress,
    userAgent: options.meta.userAgent,
  });

  return row;
}

export interface UpdateReportRunStatusInput {
  status: ReportRunStatus;
  storageKey?: string | null;
  sizeBytes?: number | null;
  errorMessage?: string | null;
  emailedAt?: Date | null;
}

/** Called by the BullMQ render worker (P3) as the job progresses. Kept
 *  here so the queue layer has a single typed entry point. */
export async function updateReportRunStatus(
  id: string,
  portId: string,
  patch: UpdateReportRunStatusInput,
): Promise<ReportRun> {
  const existing = await getReportRun(id, portId);
  const updates: Partial<NewReportRun> = { status: patch.status };
  if (patch.storageKey !== undefined) updates.storageKey = patch.storageKey;
  if (patch.sizeBytes !== undefined) updates.sizeBytes = patch.sizeBytes;
  if (patch.errorMessage !== undefined) updates.errorMessage = patch.errorMessage;
  if (patch.emailedAt !== undefined) updates.emailedAt = patch.emailedAt;
  if (patch.status === 'complete' || patch.status === 'failed') {
    updates.completedAt = new Date();
  }

  const [row] = await db
    .update(reportRuns)
    .set(updates)
    .where(and(eq(reportRuns.id, id), eq(reportRuns.portId, portId)))
    .returning();
  if (!row) throw new NotFoundError('Report run');
  // Suppress the silent-no-op gotcha where status went pending→pending —
  // the caller almost certainly meant to advance state.
  if (existing.status === patch.status && patch.status === 'pending') {
    // Allow but flag — useful for tests asserting idempotency.
  }
  return row;
}
