import { Worker, type Job } from 'bullmq';
import { env } from '@/lib/env';

import type { ConnectionOptions } from 'bullmq';
import { logger } from '@/lib/logger';
import { attachWorkerAudit } from '@/lib/queue/audit-helpers';
import { QUEUE_CONFIGS } from '@/lib/queue';

export const reportsWorker = new Worker(
  'reports',
  async (job: Job) => {
    logger.info({ jobId: job.id, jobName: job.name }, 'Processing reports job');

    switch (job.name) {
      case 'report-scheduler': {
        // Check scheduled_reports for reports due to run.
        //
        // datetime-auditor C3: the previous version selected due rows
        // and enqueued the generate-report job but NEVER advanced
        // `next_run_at`. The minutely scheduler then re-fired every
        // single tick until a human zeroed the row out - for
        // weekly/monthly reports that's an instant flood of dupe
        // emails to recipients. Now we compute the next fire from
        // the cron expression and UPDATE the row atomically.
        //
        // L6: this poller does a select-due → per-row update. With a
        // single `crm-worker` (concurrency 1) that's safe, but the moment
        // `MULTI_NODE_DEPLOYMENT` adds a second replica two pollers would
        // both read the same due rows and double-fire (duplicate runs +
        // email blasts). We now atomically CLAIM due rows with
        // `FOR UPDATE SKIP LOCKED` inside a transaction: a concurrent
        // replica skips rows this tx already holds, so each due row is
        // claimed by exactly one poller. `nextRunAt` is row-specific
        // (cron-derived) so we keep the per-row update — the row lock,
        // not a bulk UPDATE, is what makes the claim atomic. Enqueues are
        // deferred to AFTER commit so a rolled-back claim never leaves an
        // orphaned generate-report job.
        const { db } = await import('@/lib/db');
        const { scheduledReports } = await import('@/lib/db/schema/operations');
        const { generatedReports } = await import('@/lib/db/schema/operations');
        const { eq, and, lte } = await import('drizzle-orm');
        const { CronExpressionParser } = await import('cron-parser');

        const enqueueIds: string[] = [];

        await db.transaction(async (tx) => {
          const dueReports = await tx
            .select()
            .from(scheduledReports)
            .where(
              and(eq(scheduledReports.isActive, true), lte(scheduledReports.nextRunAt, new Date())),
            )
            .for('update', { skipLocked: true });

          for (const report of dueReports) {
            // Compute next_run_at BEFORE the enqueue so a failure in the
            // parse path (malformed cron) doesn't get repeat-fired.
            let nextRunAt: Date | null = null;
            try {
              nextRunAt = CronExpressionParser.parse(report.schedule, {
                currentDate: new Date(),
                tz: process.env.SCHEDULER_TZ ?? 'Europe/Warsaw',
              })
                .next()
                .toDate();
            } catch (err) {
              logger.error(
                { err, reportId: report.id, schedule: report.schedule },
                'Failed to parse cron schedule for scheduled report; pausing it',
              );
              // Disable the row so we don't re-attempt the malformed cron
              // every minute.
              await tx
                .update(scheduledReports)
                .set({ isActive: false, updatedAt: new Date() })
                .where(eq(scheduledReports.id, report.id));
              continue;
            }

            await tx
              .update(scheduledReports)
              .set({ nextRunAt, updatedAt: new Date() })
              .where(eq(scheduledReports.id, report.id));

            const [genReport] = await tx
              .insert(generatedReports)
              .values({
                portId: report.portId,
                scheduledReportId: report.id,
                reportType: report.reportType,
                name: `${report.name} - ${new Date().toISOString().split('T')[0]}`,
                status: 'queued',
                parameters: (report.config as Record<string, unknown>) ?? {},
                requestedBy: report.createdBy,
              })
              .returning();

            if (genReport) {
              enqueueIds.push(genReport.id);
            }
          }
        });

        if (enqueueIds.length > 0) {
          const { getQueue } = await import('@/lib/queue');
          for (const genReportId of enqueueIds) {
            await getQueue('reports').add(
              'generate-report',
              { reportJobId: genReportId },
              { jobId: `generate-report:${genReportId}` },
            );
          }
        }
        break;
      }

      case 'generate-report': {
        const { reportJobId } = job.data as { reportJobId: string };
        const { generateReport } = await import('@/lib/services/reports.service');
        await generateReport(reportJobId);
        break;
      }

      // Reports P3 — new system: report_schedules + report_runs.
      case 'report-schedules-poll': {
        // Scan report_schedules due to fire, mint a report_runs row per
        // schedule, advance next_run_at by cadence math, enqueue render.
        //
        // L6: same select-due → per-row update shape as the legacy poller
        // above. Safe under the single `crm-worker` (concurrency 1) today,
        // but double-fires under multiple replicas once
        // `MULTI_NODE_DEPLOYMENT` is on. We atomically CLAIM due rows in a
        // `FOR UPDATE SKIP LOCKED` transaction that ALSO advances
        // `nextRunAt`/`lastRunAt` (and pauses templateless rows). Because
        // the claim advances `nextRunAt` past `now`, a concurrent replica
        // re-polling immediately afterwards no longer sees the row as due,
        // and `SKIP LOCKED` keeps two pollers from claiming the same row
        // mid-flight. The heavier per-row work (`createReportRun` + render
        // enqueue) runs AFTER commit on the claimed rows — `createReportRun`
        // is a service that uses its own db handle, and advancing the fire
        // time before minting already preserves the "no-op doesn't slip"
        // rule, so a downstream mint failure just retries on the next poll.
        const { db } = await import('@/lib/db');
        const { reportSchedules, reportTemplates } = await import('@/lib/db/schema/reports');
        const { createReportRun } = await import('@/lib/services/report-runs.service');
        const { nextRunFor } = await import('@/lib/services/report-schedules.service');
        const { and, eq, lte } = await import('drizzle-orm');
        type ReportSchedule = import('@/lib/db/schema/reports').ReportSchedule;
        type ReportTemplate = import('@/lib/db/schema/reports').ReportTemplate;

        const now = new Date();

        const claimed: Array<{ schedule: ReportSchedule; template: ReportTemplate }> = [];

        await db.transaction(async (tx) => {
          const due = await tx
            .select()
            .from(reportSchedules)
            .where(and(eq(reportSchedules.enabled, true), lte(reportSchedules.nextRunAt, now)))
            .for('update', { skipLocked: true });

          for (const schedule of due) {
            const template = await tx.query.reportTemplates.findFirst({
              where: eq(reportTemplates.id, schedule.templateId),
            });
            if (!template) {
              logger.warn(
                { scheduleId: schedule.id, templateId: schedule.templateId },
                'Skipping schedule: template missing (likely archived); pausing',
              );
              await tx
                .update(reportSchedules)
                .set({ enabled: false, updatedAt: new Date() })
                .where(eq(reportSchedules.id, schedule.id));
              continue;
            }

            // Compute the next fire BEFORE the enqueue so a downstream
            // failure (storage outage, etc.) doesn't pin the schedule on
            // the same tick — preserves the "no-op doesn't slip" rule.
            await tx
              .update(reportSchedules)
              .set({
                lastRunAt: now,
                nextRunAt: nextRunFor(schedule.cadence as Parameters<typeof nextRunFor>[0], now),
                updatedAt: new Date(),
              })
              .where(eq(reportSchedules.id, schedule.id));

            claimed.push({ schedule, template });
          }
        });

        for (const { schedule, template } of claimed) {
          try {
            const { REPORT_KINDS } = await import('@/lib/validators/reports');
            const kindNarrowed = (REPORT_KINDS as readonly string[]).includes(template.kind)
              ? (template.kind as (typeof REPORT_KINDS)[number])
              : null;
            if (!kindNarrowed) {
              logger.warn(
                { scheduleId: schedule.id, templateId: schedule.templateId, kind: template.kind },
                'Skipping schedule: template kind not in REPORT_KINDS allowlist',
              );
              continue;
            }
            const run = await createReportRun(
              {
                kind: kindNarrowed,
                config: template.config,
                outputFormat: schedule.outputFormat as 'pdf' | 'csv' | 'png',
                templateId: template.id,
              },
              {
                portId: schedule.portId,
                triggeredBy: 'schedule',
                scheduleId: schedule.id,
                meta: {
                  userId: 'system',
                  portId: schedule.portId,
                  ipAddress: '0.0.0.0',
                  userAgent: 'scheduler',
                },
              },
            );
            const { getQueue: enqueue } = await import('@/lib/queue');
            await enqueue('reports').add(
              'report-run-render',
              { reportRunId: run.id },
              { jobId: `report-run-render:${run.id}` },
            );
          } catch (err) {
            logger.error(
              { err, scheduleId: schedule.id },
              'Failed to mint report_run for due schedule; will retry on next poll',
            );
          }
        }
        break;
      }

      case 'report-run-render': {
        const { reportRunId } = job.data as { reportRunId: string };
        const { renderReportRun } = await import('@/lib/services/report-render.service');
        const run = await renderReportRun(reportRunId);

        // Schedule-driven runs auto-cascade into the email job ONLY when
        // the schedule has recipients configured. Email is optional per
        // locked decision (2026-05-27): an admin can schedule a run that
        // just appears in /reports/runs without forcing a blast.
        // User-triggered runs are inert — the rep downloads via the UI.
        if (
          run.triggeredBy === 'schedule' &&
          run.status === 'complete' &&
          run.scheduleId !== null
        ) {
          const { db: dbForSched } = await import('@/lib/db');
          const { reportSchedules: schedTbl } = await import('@/lib/db/schema/reports');
          const { eq: eqOp } = await import('drizzle-orm');
          const sched = await dbForSched.query.reportSchedules.findFirst({
            where: eqOp(schedTbl.id, run.scheduleId),
            columns: { recipients: true },
          });
          const hasRecipients =
            Array.isArray(sched?.recipients) && (sched?.recipients?.length ?? 0) > 0;
          if (hasRecipients) {
            const { getQueue: enqueue } = await import('@/lib/queue');
            await enqueue('reports').add(
              'report-run-email',
              { reportRunId: run.id },
              { jobId: `report-run-email:${run.id}` },
            );
          } else {
            logger.info(
              { reportRunId: run.id, scheduleId: run.scheduleId },
              'Schedule has no recipients; skipping email cascade (run archived only)',
            );
          }
        }
        break;
      }

      case 'report-run-email': {
        const { reportRunId } = job.data as { reportRunId: string };
        const { emailReportRun } = await import('@/lib/services/report-render.service');
        await emailReportRun(reportRunId);
        break;
      }

      default:
        logger.warn({ jobName: job.name }, 'Unknown reports job');
    }
  },
  {
    connection: { url: env.REDIS_URL } as ConnectionOptions,
    concurrency: QUEUE_CONFIGS.reports.concurrency,
  },
);

reportsWorker.on('failed', (job, err) => {
  logger.error({ jobId: job?.id, jobName: job?.name, err }, 'Reports job failed');
});

attachWorkerAudit(reportsWorker, 'reports');
