/**
 * Reports P3 — render + email orchestration for `report_runs` rows.
 *
 * Two entry points are called from the BullMQ reports worker:
 *
 *   - `renderReportRun(reportRunId)` — fetches the run, advances to
 *     `rendering`, generates the artefact via the registered renderer
 *     for that `kind`, uploads to storage, and stamps the run row with
 *     storage key + size + status='complete' (or 'failed' + errorMessage).
 *   - `emailReportRun(reportRunId)` — for runs born from a schedule:
 *     reads the schedule's recipients + the stored artefact and ships
 *     an email with the file attached via the existing sendEmail helper.
 *
 * Kind-specific renderers live in REPORT_RENDER_MAP. v1 wires the four
 * new-system kinds (dashboard/clients/berths/interests) onto the
 * existing legacy fetcher+template pairs; dedicated per-kind templates
 * land alongside the builder UI (P4+).
 */

import { createHash } from 'node:crypto';

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

import { db } from '@/lib/db';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { reportRuns, reportSchedules, type ReportRun } from '@/lib/db/schema/reports';
import { ports } from '@/lib/db/schema/ports';
import { files } from '@/lib/db/schema/documents';
import { buildStoragePath } from '@/lib/minio';
import { getStorageBackend } from '@/lib/storage';
import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo';
import { renderPdf } from '@/lib/pdf/render';
import { sendEmail } from '@/lib/email';
import { updateReportRunStatus, userCanAccessPort } from '@/lib/services/report-runs.service';
import { CodedError, NotFoundError } from '@/lib/errors';
import {
  fetchActivityData,
  fetchOccupancyData,
  fetchPipelineData,
  fetchRevenueData,
  type ActivityData,
  type OccupancyData,
  type PipelineData,
  type RevenueData,
} from '@/lib/services/report-generators';
import { ActivityReportPdf } from '@/lib/pdf/templates/reports/activity-report';
import { OccupancyReportPdf } from '@/lib/pdf/templates/reports/occupancy-report';
import { PipelineReportPdf } from '@/lib/pdf/templates/reports/pipeline-report';
import { RevenueReportPdf } from '@/lib/pdf/templates/reports/revenue-report';
import { PayloadReportDocument } from '@/lib/pdf/reports/payload-report';
import { absolutizeBrandingUrl } from '@/lib/branding/url';
import { getPortBrandingConfig } from '@/lib/services/port-config';
import {
  buildSalesReportPayload,
  buildOperationalReportPayload,
} from '@/lib/services/reports/build-payload';
import { renderToBuffer } from '@react-pdf/renderer';
import { createElement } from 'react';
import type { ReportPayload } from '@/lib/reports/types';

interface RenderCtx {
  portName: string;
  logoBuffer: Buffer | null;
}

interface KindRenderer {
  fetchData: (portId: string, params: Record<string, unknown>) => Promise<unknown>;
  render: (data: unknown, ctx: RenderCtx) => Promise<Buffer>;
  /** CSV serializer for P6. Each kind emits rows shaped to its data; the
   *  helper below converts a 2-D string array to RFC-4180 bytes. */
  toCsv: (data: unknown) => Buffer;
}

/**
 * Escape a single cell for RFC-4180 CSV: double-quote always, double
 * any internal quotes. Null/undefined → empty quoted cell.
 */
function csvCell(value: unknown): string {
  if (value === null || value === undefined) return '""';
  const s = String(value);
  return `"${s.replace(/"/g, '""')}"`;
}

function rowsToCsv(rows: Array<Array<string | number | null | undefined>>): Buffer {
  const lines = rows.map((r) => r.map(csvCell).join(','));
  return Buffer.from(lines.join('\r\n') + '\r\n', 'utf-8');
}

/**
 * L5 — deterministic artefact file id per report run.
 *
 * The render path can crash between `backend.put` and the `files`
 * insert / status write; BullMQ then retries (reports maxAttempts 3).
 * Deriving the `files.id` (and therefore the storage key, which embeds
 * the file id) deterministically from the run id makes the retry land
 * on the SAME storage key and the SAME PK, so `onConflictDoNothing`
 * collapses the duplicate insert and `backend.put` overwrites in place
 * instead of leaking an orphaned blob + dangling `files` row.
 *
 * We format a SHA-256 of the run id as a UUID-shaped string so the
 * value is a stable, collision-free `files.id` (UUIDv4 columns accept
 * any 36-char UUID-shaped text).
 */
function deterministicFileId(runId: string): string {
  const h = createHash('sha256').update(`report-run:${runId}`).digest('hex');
  return [h.slice(0, 8), h.slice(8, 12), h.slice(12, 16), h.slice(16, 20), h.slice(20, 32)].join(
    '-',
  );
}

const REPORT_RENDER_MAP: Record<string, KindRenderer> = {
  dashboard: {
    fetchData: fetchPipelineData as KindRenderer['fetchData'],
    render: async (data, ctx) =>
      renderPdf(
        PipelineReportPdf({
          portName: ctx.portName,
          logoBuffer: ctx.logoBuffer ?? null,
          data: data as PipelineData,
        }),
      ),
    toCsv: (data) => {
      const d = data as PipelineData;
      const rows: Array<Array<string | number>> = [['Section', 'Key', 'Value']];
      for (const [stage, ct] of Object.entries(d.stageCounts ?? {})) {
        rows.push(['Stage count', stage, ct]);
      }
      for (const i of d.topInterests ?? []) {
        rows.push(['Top interest', i.id, i.berthPrice ?? '']);
      }
      rows.push(['Meta', 'generatedAt', d.generatedAt ?? '']);
      return rowsToCsv(rows);
    },
  },
  clients: {
    fetchData: fetchActivityData as KindRenderer['fetchData'],
    render: async (data, ctx) =>
      renderPdf(
        ActivityReportPdf({
          portName: ctx.portName,
          logoBuffer: ctx.logoBuffer ?? null,
          data: data as ActivityData,
        }),
      ),
    toCsv: (data) => {
      const d = data as ActivityData;
      const rows: Array<Array<string | number>> = [
        ['id', 'createdAt', 'action', 'entityType', 'entityId', 'userId'],
      ];
      for (const l of d.logs ?? []) {
        rows.push([
          l.id,
          l.createdAt instanceof Date ? l.createdAt.toISOString() : String(l.createdAt),
          l.action,
          l.entityType,
          l.entityId ?? '',
          l.userId ?? '',
        ]);
      }
      return rowsToCsv(rows);
    },
  },
  berths: {
    fetchData: fetchOccupancyData as KindRenderer['fetchData'],
    render: async (data, ctx) =>
      renderPdf(
        OccupancyReportPdf({
          portName: ctx.portName,
          logoBuffer: ctx.logoBuffer ?? null,
          data: data as OccupancyData,
        }),
      ),
    toCsv: (data) => {
      const d = data as OccupancyData;
      const rows: Array<Array<string | number>> = [['metric', 'value']];
      rows.push(['totalBerths', d.totalBerths]);
      rows.push(['occupancyRate', d.occupancyRate]);
      for (const [status, ct] of Object.entries(d.statusCounts ?? {})) {
        rows.push([`status:${status}`, ct]);
      }
      rows.push(['generatedAt', d.generatedAt ?? '']);
      return rowsToCsv(rows);
    },
  },
  interests: {
    fetchData: fetchRevenueData as KindRenderer['fetchData'],
    render: async (data, ctx) =>
      renderPdf(
        RevenueReportPdf({
          portName: ctx.portName,
          logoBuffer: ctx.logoBuffer ?? null,
          data: data as RevenueData,
        }),
      ),
    toCsv: (data) => {
      const d = data as RevenueData;
      const rows: Array<Array<string | number>> = [['metric', 'value']];
      rows.push(['totalCompleted', d.totalCompleted]);
      rows.push(['totalForecast', d.totalForecast]);
      for (const [stage, amt] of Object.entries(d.stageRevenue ?? {})) {
        rows.push([`stageRevenue:${stage}`, amt]);
      }
      for (const [stage, w] of Object.entries(d.pipelineWeights ?? {})) {
        rows.push([`pipelineWeight:${stage}`, w]);
      }
      rows.push(['generatedAt', d.generatedAt ?? '']);
      return rowsToCsv(rows);
    },
  },
};

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

  if (run.status === 'complete' && run.storageKey) {
    return run;
  }

  await updateReportRunStatus(run.id, run.portId, { status: 'rendering' });

  let putStoragePath: string | null = null;
  try {
    // Standalone report kinds (sales, operational) take a different
    // render path: they build a generic ReportPayload from saved-template
    // config + live data, then feed it through PayloadReportDocument.
    // The legacy 4 kinds still flow through REPORT_RENDER_MAP below.
    if (run.kind === 'sales' || run.kind === 'operational') {
      return await renderStandaloneReportRun(run);
    }

    const renderer = REPORT_RENDER_MAP[run.kind];
    if (!renderer) {
      throw new CodedError('VALIDATION_ERROR', {
        internalMessage: `No renderer registered for report kind "${run.kind}"`,
      });
    }

    const port = await db.query.ports.findFirst({ where: eq(ports.id, run.portId) });
    if (!port) {
      throw new Error(`Cannot render report ${run.id}: port ${run.portId} not found`);
    }

    // P7: optional cover-brand swap. When config.coverBrandPortId points
    // at a port the triggering user has access to, the cover-page logo +
    // port name come from THAT port's brand kit instead of the report's
    // source port. Useful for cross-port leadership decks; falls back to
    // the source port when the override port is missing / inaccessible.
    //
    // H11 defense-in-depth: enqueue-time validation (createReportRun)
    // already rejects forbidden overrides, but a stale/forged config row
    // could still reach the worker. Only honour the override when it equals
    // the run's own port OR the triggering user is a member of that port
    // (super-admins included via userCanAccessPort). Otherwise silently
    // fall back to the source port's branding — a forged config can never
    // leak a foreign tenant's logo/name onto the rendered PDF.
    const params = (run.config as Record<string, unknown>) ?? {};
    const requestedBrandPortId =
      typeof params.coverBrandPortId === 'string' && params.coverBrandPortId.length > 0
        ? params.coverBrandPortId
        : null;
    const overrideAllowed =
      requestedBrandPortId !== null &&
      (requestedBrandPortId === run.portId ||
        (await userCanAccessPort(run.triggeredByUserId, requestedBrandPortId)));
    if (requestedBrandPortId !== null && !overrideAllowed) {
      logger.warn(
        {
          reportRunId: run.id,
          runPortId: run.portId,
          requestedBrandPortId,
          triggeredByUserId: run.triggeredByUserId,
        },
        'Ignoring cover-brand override: triggering user is not a member of the requested port (H11)',
      );
    }
    const overrideBrandPortId = overrideAllowed ? requestedBrandPortId : null;
    const brandPortId = overrideBrandPortId ?? run.portId;
    const brandPort =
      overrideBrandPortId === null
        ? port
        : ((await db.query.ports.findFirst({ where: eq(ports.id, brandPortId) })) ?? port);

    const logo = await resolvePortLogo(brandPort.id).catch(() => ({
      buffer: null as Buffer | null,
    }));
    const ctx: RenderCtx = { portName: brandPort.name, logoBuffer: logo.buffer ?? null };

    const data = await renderer.fetchData(run.portId, params);

    // P6: branch on outputFormat. PDF is the v1 default; CSV serializes
    // the same fetched data via the kind-specific toCsv mapper. PNG is
    // deferred — fail the run with a descriptive message so the rep
    // sees it land as 'failed' in /reports/runs (no partial blob lands).
    let bytes: Buffer;
    let extension: 'pdf' | 'csv';
    let contentType: string;
    if (run.outputFormat === 'csv') {
      bytes = renderer.toCsv(data);
      extension = 'csv';
      contentType = 'text/csv; charset=utf-8';
    } else if (run.outputFormat === 'png') {
      throw new CodedError('VALIDATION_ERROR', {
        internalMessage:
          'PNG output is not yet implemented. Use PDF or CSV for now (Reports P6 PNG deferred).',
      });
    } else {
      bytes = await renderer.render(data, ctx);
      extension = 'pdf';
      contentType = 'application/pdf';
    }

    // L5: deterministic per-run id + storage key so a retry after a
    // mid-render crash overwrites the same blob and no-ops the duplicate
    // `files` insert instead of leaking an orphan.
    const fileId = deterministicFileId(run.id);
    const storagePath = buildStoragePath(port.slug, 'reports', run.id, fileId, extension);

    const backend = await getStorageBackend();
    await backend.put(storagePath, bytes, {
      contentType,
      sizeBytes: bytes.length,
    });
    putStoragePath = storagePath;

    await db
      .insert(files)
      .values({
        id: fileId,
        portId: run.portId,
        filename: `${run.kind}-${run.id.slice(0, 8)}.${extension}`,
        originalName: `${run.kind}-report.${extension}`,
        mimeType: contentType,
        sizeBytes: String(bytes.length),
        storagePath,
        storageBucket: env.MINIO_BUCKET,
        category: 'misc',
        uploadedBy: run.triggeredByUserId ?? 'system',
      })
      .onConflictDoNothing();

    const updated = await updateReportRunStatus(run.id, run.portId, {
      status: 'complete',
      storageKey: fileId,
      sizeBytes: bytes.length,
    });
    putStoragePath = null;
    return updated;
  } catch (err) {
    logger.error({ err, reportRunId: run.id }, 'renderReportRun failed');
    await updateReportRunStatus(run.id, run.portId, {
      status: 'failed',
      errorMessage: err instanceof Error ? err.message : String(err),
    }).catch(() => undefined);

    if (putStoragePath) {
      try {
        await (await getStorageBackend()).delete(putStoragePath);
      } catch (compErr) {
        logger.error(
          { compErr, putStoragePath },
          'Compensating storage.delete failed after render error',
        );
      }
    }
    throw err;
  }
}

/**
 * Schedule-driven email side effect. Looks up the schedule's recipients
 * and ships an email with the rendered PDF attached.
 *
 * M9 — idempotent against BullMQ retries (reports maxAttempts 3):
 *   - Early-returns when `emailedAt` is already stamped, so a retry never
 *     re-blasts recipients who already received the report.
 *   - Stamps `emailedAt` BEFORE the recipient loop, so a transient SMTP
 *     failure on recipient N does not re-send to 1..N-1 on the next
 *     attempt — at most the failing send is lost, never duplicated.
 *   - Treats per-recipient send failures as logged-not-thrown so one bad
 *     address cannot re-trigger the whole loop on retry.
 */
export async function emailReportRun(reportRunId: string): Promise<void> {
  const run = await db.query.reportRuns.findFirst({
    where: eq(reportRuns.id, reportRunId),
  });
  if (!run) throw new NotFoundError('Report run');
  if (run.status !== 'complete' || !run.storageKey) {
    throw new CodedError('VALIDATION_ERROR', {
      internalMessage: `Cannot email report ${run.id} — status=${run.status}, storageKey=${run.storageKey}`,
    });
  }
  // M9: already emailed on a prior attempt — never re-send on BullMQ retry.
  if (run.emailedAt) {
    logger.info(
      { reportRunId: run.id, emailedAt: run.emailedAt },
      'Report already emailed; skipping (M9 idempotency)',
    );
    return;
  }
  if (!run.scheduleId) {
    logger.info({ reportRunId: run.id }, 'Skipping email for user-triggered report (no schedule)');
    return;
  }

  const schedule = await db.query.reportSchedules.findFirst({
    where: eq(reportSchedules.id, run.scheduleId),
  });
  if (!schedule) {
    logger.warn(
      { reportRunId: run.id, scheduleId: run.scheduleId },
      'Schedule deleted before email could fire; skipping',
    );
    return;
  }

  const recipients = (schedule.recipients ?? []).filter(
    (r): r is { name?: string; email: string } => Boolean(r?.email),
  );
  if (recipients.length === 0) {
    logger.info({ reportRunId: run.id }, 'No recipients on schedule; nothing to email');
    return;
  }

  const port = await db.query.ports.findFirst({ where: eq(ports.id, run.portId) });
  if (!port) throw new Error(`Port ${run.portId} missing during emailReportRun`);

  const fileRow = await db.query.files.findFirst({
    where: and(eq(files.id, run.storageKey), eq(files.portId, run.portId)),
  });
  if (!fileRow) {
    throw new Error(`Report artefact file ${run.storageKey} missing during emailReportRun`);
  }

  const subject = `${port.name} · ${run.kind} report`;
  const html = `<p>Your scheduled ${run.kind} report is attached.</p>`;

  // M9: stamp `emailedAt` BEFORE sending so a transient SMTP failure
  // mid-loop cannot cause a BullMQ retry to re-send to the recipients
  // who already received the report (the early-return above now fires).
  await updateReportRunStatus(run.id, run.portId, {
    status: 'complete',
    emailedAt: new Date(),
  });

  // M9: log-not-throw per recipient — one bad address must not re-blast
  // the rest on retry (the run is already marked emailed). At-most-once
  // delivery per recipient; a failed send is logged and dropped.
  for (const recipient of recipients) {
    try {
      await sendEmail(recipient.email, subject, html, undefined, undefined, run.portId, [
        { fileId: fileRow.id, filename: fileRow.originalName ?? `${run.kind}-report.pdf` },
      ]);
    } catch (err) {
      logger.error(
        { err, reportRunId: run.id, recipient: recipient.email },
        'Failed to email scheduled report to recipient; skipping (run already marked emailed)',
      );
    }
  }
}

/**
 * Render path for the standalone Sales / Operational reports. Builds the
 * shared `ReportPayload` from the saved-template config + live data, then
 * routes through `PayloadReportDocument` — same path the interactive
 * Export PDF button uses. Output format is PDF; CSV/XLSX for scheduled
 * runs is not yet wired (use the interactive Export for those formats).
 */
async function renderStandaloneReportRun(run: ReportRun): Promise<ReportRun> {
  let putStoragePath: string | null = null;
  try {
    const port = await db.query.ports.findFirst({ where: eq(ports.id, run.portId) });
    if (!port) {
      throw new Error(`Cannot render report ${run.id}: port ${run.portId} not found`);
    }

    let payload: ReportPayload;
    if (run.kind === 'sales') {
      payload = await buildSalesReportPayload(
        run.portId,
        run.config as Parameters<typeof buildSalesReportPayload>[1],
      );
    } else {
      payload = await buildOperationalReportPayload(
        run.portId,
        run.config as Parameters<typeof buildOperationalReportPayload>[1],
      );
    }

    // CSV / XLSX rendering on the worker is deferred — PDF only for v1.
    // The interactive Export button covers CSV + XLSX client-side.
    if (run.outputFormat !== 'pdf') {
      throw new CodedError('VALIDATION_ERROR', {
        internalMessage: `Scheduled ${run.kind} reports currently support PDF only (got ${run.outputFormat}).`,
      });
    }

    const cfg = await getPortBrandingConfig(run.portId);
    const branding = {
      logoUrl: absolutizeBrandingUrl(cfg.logoUrl),
      primaryColor: cfg.primaryColor,
      portName: port.name,
    };
    const generatedAt = new Date().toISOString();

    const element = createElement(PayloadReportDocument, {
      payload,
      branding,
      generatedAt,
    });
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const bytes = (await renderToBuffer(element as any)) as Buffer;

    // L5: deterministic per-run id + storage key so a retry after a
    // mid-render crash overwrites the same blob and no-ops the duplicate
    // `files` insert instead of leaking an orphan.
    const fileId = deterministicFileId(run.id);
    const storagePath = buildStoragePath(port.slug, 'reports', run.id, fileId, 'pdf');
    const backend = await getStorageBackend();
    await backend.put(storagePath, bytes, {
      contentType: 'application/pdf',
      sizeBytes: bytes.length,
    });
    putStoragePath = storagePath;

    await db
      .insert(files)
      .values({
        id: fileId,
        portId: run.portId,
        filename: `${run.kind}-${run.id.slice(0, 8)}.pdf`,
        originalName: `${run.kind}-report.pdf`,
        mimeType: 'application/pdf',
        sizeBytes: String(bytes.length),
        storagePath,
        storageBucket: env.MINIO_BUCKET,
        category: 'misc',
        uploadedBy: run.triggeredByUserId ?? 'system',
      })
      .onConflictDoNothing();

    const updated = await updateReportRunStatus(run.id, run.portId, {
      status: 'complete',
      storageKey: fileId,
      sizeBytes: bytes.length,
    });
    putStoragePath = null;
    return updated;
  } catch (err) {
    logger.error({ err, reportRunId: run.id }, 'renderStandaloneReportRun failed');
    await updateReportRunStatus(run.id, run.portId, {
      status: 'failed',
      errorMessage: err instanceof Error ? err.message : String(err),
    }).catch(() => undefined);
    if (putStoragePath) {
      try {
        await (await getStorageBackend()).delete(putStoragePath);
      } catch (compErr) {
        logger.error(
          { compErr, putStoragePath },
          'Compensating storage.delete failed after render error',
        );
      }
    }
    throw err;
  }
}
