import { createHash } from 'node:crypto';

import { and, desc, eq } from 'drizzle-orm';
import type { ReactElement } from 'react';
import type { DocumentProps } from '@react-pdf/renderer';

import { db } from '@/lib/db';
import { generatedReports } from '@/lib/db/schema/operations';
import { notifications } from '@/lib/db/schema/operations';
import { files } from '@/lib/db/schema/documents';
import { ports } from '@/lib/db/schema/ports';
import { renderPdf } from '@/lib/pdf/render';
import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo';
import { buildStoragePath } from '@/lib/minio/index';
import { getStorageBackend, presignDownloadUrl } from '@/lib/storage';
import { emitToRoom } from '@/lib/socket/server';
import { getQueue } from '@/lib/queue';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { CodedError, ConflictError, NotFoundError } from '@/lib/errors';

import {
  fetchPipelineData,
  fetchRevenueData,
  fetchActivityData,
  fetchOccupancyData,
  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 type { RequestReportInput, ListReportsInput } from '@/lib/validators/reports';

// ─── Report Type Map ──────────────────────────────────────────────────────────

interface ReportContext {
  portName: string;
  logoBuffer: Buffer | null;
  dateFrom?: string;
  dateTo?: string;
  currency?: string;
}

const REPORT_TYPE_MAP = {
  pipeline: {
    fetchData: fetchPipelineData,
    render: (data: PipelineData, ctx: ReportContext) => (
      <PipelineReportPdf portName={ctx.portName} logoBuffer={ctx.logoBuffer} data={data} />
    ),
  },
  revenue: {
    fetchData: fetchRevenueData,
    render: (data: RevenueData, ctx: ReportContext) => (
      <RevenueReportPdf
        portName={ctx.portName}
        logoBuffer={ctx.logoBuffer}
        data={data}
        dateFrom={ctx.dateFrom}
        dateTo={ctx.dateTo}
        currency={ctx.currency}
      />
    ),
  },
  activity: {
    fetchData: fetchActivityData,
    render: (data: ActivityData, ctx: ReportContext) => (
      <ActivityReportPdf
        portName={ctx.portName}
        logoBuffer={ctx.logoBuffer}
        data={data}
        dateFrom={ctx.dateFrom}
        dateTo={ctx.dateTo}
      />
    ),
  },
  occupancy: {
    fetchData: fetchOccupancyData,
    render: (data: OccupancyData, ctx: ReportContext) => (
      <OccupancyReportPdf portName={ctx.portName} logoBuffer={ctx.logoBuffer} data={data} />
    ),
  },
} as const;

type ReportType = keyof typeof REPORT_TYPE_MAP;

// ─── requestReport ────────────────────────────────────────────────────────────

export async function requestReport(portId: string, userId: string, data: RequestReportInput) {
  const [report] = await db
    .insert(generatedReports)
    .values({
      portId,
      reportType: data.reportType,
      name: data.name,
      status: 'queued',
      parameters: data.parameters ?? {},
      requestedBy: userId,
    })
    .returning();

  if (!report) {
    throw new CodedError('INSERT_RETURNING_EMPTY', {
      internalMessage: 'Failed to create report record',
    });
  }

  await getQueue('reports').add('generate-report', { reportJobId: report.id });

  emitToRoom(`user:${userId}`, 'report:queued', {
    reportId: report.id,
    reportType: report.reportType,
    name: report.name,
  });

  return report;
}

// ─── listReports ──────────────────────────────────────────────────────────────

export async function listReports(portId: string, query: ListReportsInput) {
  const conditions = [eq(generatedReports.portId, portId)];
  if (query.status) {
    conditions.push(eq(generatedReports.status, query.status));
  }

  const offset = (query.page - 1) * query.limit;

  const [rows, countResult] = await Promise.all([
    db
      .select()
      .from(generatedReports)
      .where(and(...conditions))
      .orderBy(desc(generatedReports.createdAt))
      .limit(query.limit)
      .offset(offset),
    db.$count(generatedReports, and(...conditions)),
  ]);

  return {
    data: rows,
    total: Number(countResult),
  };
}

// ─── getReport ────────────────────────────────────────────────────────────────

export async function getReport(reportId: string, portId: string) {
  const report = await db.query.generatedReports.findFirst({
    where: and(eq(generatedReports.id, reportId), eq(generatedReports.portId, portId)),
  });

  if (!report) {
    throw new NotFoundError('Report');
  }

  return report;
}

// ─── getDownloadUrl ───────────────────────────────────────────────────────────

export async function getDownloadUrl(reportId: string, portId: string) {
  const report = await db.query.generatedReports.findFirst({
    where: and(eq(generatedReports.id, reportId), eq(generatedReports.portId, portId)),
  });

  if (!report) {
    throw new NotFoundError('Report');
  }

  if (report.status !== 'ready' || !report.fileId) {
    throw new ConflictError('Report is not ready for download');
  }

  // Defense-in-depth: scope the file lookup to the same port. report.fileId
  // is currently assigned at generation time in the same-port flow, but
  // pinning portId in the WHERE means a future bulk-import or admin-bypass
  // path that ever crossed port boundaries cannot leak a foreign file via
  // a presigned URL.
  const file = await db.query.files.findFirst({
    where: and(eq(files.id, report.fileId), eq(files.portId, portId)),
  });

  if (!file) {
    throw new NotFoundError('File');
  }

  const url = await presignDownloadUrl(file.storagePath);
  return { url };
}

// ─── generateReport ───────────────────────────────────────────────────────────

/**
 * L5 — deterministic artefact file id per legacy report job.
 *
 * `generateReport` can crash between `backend.put` and the
 * `generatedReports` 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 job id
 * makes the retry land on the SAME storage key and SAME PK, so the blob
 * is overwritten in place and the duplicate `files` insert no-ops via
 * `onConflictDoNothing` — no orphaned blob + dangling row.
 */
function deterministicReportFileId(reportJobId: string): string {
  const h = createHash('sha256').update(`generated-report:${reportJobId}`).digest('hex');
  return [h.slice(0, 8), h.slice(8, 12), h.slice(12, 16), h.slice(16, 20), h.slice(20, 32)].join(
    '-',
  );
}

export async function generateReport(reportJobId: string): Promise<void> {
  // 1. Fetch the generatedReports record
  const report = await db.query.generatedReports.findFirst({
    where: eq(generatedReports.id, reportJobId),
  });

  if (!report) {
    throw new NotFoundError('report job');
  }

  // L5: idempotency early-return. If a prior attempt already produced the
  // artefact (status='ready' with a fileId), a BullMQ retry must not
  // re-render + re-upload + re-insert a second `files` row — that leaks an
  // orphaned blob. Bail out treating the run as already done.
  if (report.status === 'ready' && report.fileId) {
    logger.info({ reportJobId }, 'Report already generated; skipping re-render (L5 idempotency)');
    return;
  }

  const { portId, reportType, name, parameters, requestedBy } = report;

  try {
    // 2. Update status = 'processing', startedAt = now
    await db
      .update(generatedReports)
      .set({ status: 'processing', startedAt: new Date() })
      .where(eq(generatedReports.id, reportJobId));

    // 3. Look up REPORT_TYPE_MAP[reportType]
    const typeKey = reportType as ReportType;
    const config = REPORT_TYPE_MAP[typeKey];
    if (!config) {
      throw new CodedError('VALIDATION_ERROR', {
        internalMessage: `Unknown report type: ${reportType}`,
      });
    }

    const params = (parameters ?? {}) as Record<string, unknown>;

    // 4. Fetch data
    const data = await config.fetchData(portId, params);

    // 5. Get port info + brand assets for the PDF header
    const port = await db.query.ports.findFirst({
      where: eq(ports.id, portId),
    });
    // The job is keyed on a real port id - port should always resolve here.
    // If the row is missing, the deployment is in a broken state and we
    // must not stamp a competitor port's brand on the artifact. Fail loudly.
    if (!port) {
      throw new Error(
        `Cannot render report PDF: port ${portId} not found. Check report job FK integrity.`,
      );
    }
    const portName = port.name;
    const portSlug = port.slug;
    const logo = await resolvePortLogo(portId);

    // 6. Render PDF via react-pdf brand kit
    const ctx: ReportContext = {
      portName,
      logoBuffer: logo.buffer,
      dateFrom: params.dateFrom as string | undefined,
      dateTo: params.dateTo as string | undefined,
      currency: params.currency as string | undefined,
    };
    // The render fn is typed per report; we widen here because typeKey
    // narrowing across the union loses the link between data shape and
    // render signature. The fetchData / render pair is guaranteed in lock-
    // step by the map definition above.
    const renderFn = config.render as (
      data: unknown,
      ctx: ReportContext,
    ) => ReactElement<DocumentProps>;
    const pdfBytes = await renderPdf(renderFn(data, ctx));

    // 8. Build storage path.
    // L5: deterministic per-job file 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 = deterministicReportFileId(reportJobId);
    const storagePath = buildStoragePath(portSlug, 'reports', reportJobId, fileId, 'pdf');

    // 9. Upload PDF via the active storage backend (filesystem or s3)
    const buffer = Buffer.from(pdfBytes);
    const backend = await getStorageBackend();
    await backend.put(storagePath, buffer, {
      contentType: 'application/pdf',
      sizeBytes: buffer.length,
    });

    // 10. Insert into files table (idempotent on retry via deterministic id)
    let [fileRecord] = await db
      .insert(files)
      .values({
        id: fileId,
        portId,
        filename: `${name.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_${Date.now()}.pdf`,
        originalName: `${name}.pdf`,
        mimeType: 'application/pdf',
        sizeBytes: String(buffer.length),
        storagePath,
        storageBucket: env.MINIO_BUCKET,
        category: 'misc',
        uploadedBy: requestedBy,
      })
      .onConflictDoNothing()
      .returning();

    // L5: a retry that already inserted the file row on a prior attempt
    // (but crashed before the generatedReports status write) gets an empty
    // returning() from onConflictDoNothing — re-fetch the existing row
    // rather than failing.
    if (!fileRecord) {
      fileRecord = await db.query.files.findFirst({
        where: and(eq(files.id, fileId), eq(files.portId, portId)),
      });
    }

    if (!fileRecord) {
      throw new CodedError('INSERT_RETURNING_EMPTY', {
        internalMessage: 'Failed to insert file record for generated report',
      });
    }

    // 11. Update generatedReports: status='ready', fileId, completedAt
    await db
      .update(generatedReports)
      .set({
        status: 'ready',
        fileId: fileRecord.id,
        completedAt: new Date(),
        updatedAt: new Date(),
      })
      .where(eq(generatedReports.id, reportJobId));

    // 12. Emit report:ready socket event
    emitToRoom(`user:${requestedBy}`, 'report:ready', {
      reportId: reportJobId,
      name,
    });

    // 13. Create notification for requestedBy user
    await db.insert(notifications).values({
      portId,
      userId: requestedBy,
      type: 'system_alert',
      title: 'Report Ready',
      description: `Your report "${name}" is ready to download.`,
      entityType: 'report',
      entityId: reportJobId,
    });

    logger.info({ reportJobId, reportType }, 'Report generated successfully');
  } catch (err) {
    const errorMessage = err instanceof Error ? err.message : 'Unknown error';
    logger.error({ reportJobId, err }, 'Report generation failed');

    await db
      .update(generatedReports)
      .set({
        status: 'failed',
        errorMessage,
        updatedAt: new Date(),
      })
      .where(eq(generatedReports.id, reportJobId));

    emitToRoom(`user:${requestedBy}`, 'report:failed', {
      reportId: reportJobId,
      name,
      error: errorMessage,
    });

    throw err;
  }
}
