import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Queue } from 'bullmq';
import {
  SCHEDULED_JOBS_QUEUE_NAME,
  type ScheduledJobData,
  type ScheduleSpec,
} from './scheduled-job-data';
import { redisConnection } from '../../automations/queue/redis-connection';

// Retry envelope applied to EVERY enqueue/scheduler so a failed run is retried
// instead of being lost (BullMQ defaults to attempts:1). Mirrors the automations
// convention (src/automations/queue/bullmq-queue.port.ts). This static `3` is an
// upper bound; the authoritative per-job cap is `bundle.maxRetries` (0..10),
// enforced at run time in the processor via UnrecoverableError — `maxRetries`
// is not part of the register/run-if-due contract, so the port cannot read it.
// `backoff.delay` is in ms (60_000 = 1min, exponential).
const RETRY_ENVELOPE = {
  attempts: 3,
  backoff: { type: 'exponential' as const, delay: 60_000 },
};

export interface ScheduledJobsQueuePort {
  upsertSchedule(
    spec: ScheduleSpec,
  ): Promise<{ schedulerId: string | null; degraded: boolean }>;
  removeSchedule(jobId: string): Promise<void>;
  enqueueNow(
    jobId: string,
    trigger: 'MANUAL' | 'CATCHUP',
    customJobId?: string,
  ): Promise<{ id: string | null; degraded: boolean }>;
}

@Injectable()
export class BullMqScheduledJobsQueuePort
  implements ScheduledJobsQueuePort, OnModuleDestroy
{
  private readonly logger = new Logger(BullMqScheduledJobsQueuePort.name);
  private readonly queue?: Queue<ScheduledJobData>;

  constructor(configService: ConfigService) {
    const redisUrl = configService.get<string>('REDIS_URL');
    if (!redisUrl) {
      return;
    }

    this.queue = new Queue<ScheduledJobData>(SCHEDULED_JOBS_QUEUE_NAME, {
      connection: redisConnection(redisUrl),
      prefix:
        configService.get<string>('SCHEDULED_JOBS_QUEUE_PREFIX') ||
        'planfi:scheduled-jobs',
    });
  }

  async upsertSchedule(
    spec: ScheduleSpec,
  ): Promise<{ schedulerId: string | null; degraded: boolean }> {
    if (!this.queue) {
      this.logger.warn(
        `scheduled-jobs.queue.degraded jobId=${spec.jobId} type=${spec.type}`,
      );
      return { schedulerId: null, degraded: true };
    }

    const schedulerId = `sjob__${spec.jobId}`;

    if (spec.type === 'ONE_SHOT') {
      const delay = Math.max(spec.runAtMs - Date.now(), 0);
      const scheduledFor = new Date(spec.runAtMs).toISOString();

      // Remove any previous delayed one-shot for this job before re-adding so a
      // changed runAt is honoured and stale jobs don't linger. BullMQ also
      // deduplicates on jobId, but removing first lets us update the delay.
      try {
        const existing = await this.queue.getJob(schedulerId);
        if (existing) {
          await existing.remove();
        }
      } catch {
        // Job may not exist or already be locked — guard and continue
      }

      // Use a DETERMINISTIC jobId derived from the schedule so re-adds dedupe
      // and removeSchedule can find/cancel the pending delayed job.
      await this.queue.add(
        'run',
        { jobId: spec.jobId, trigger: 'SCHEDULE', scheduledFor },
        {
          jobId: schedulerId,
          delay,
          removeOnComplete: true,
          removeOnFail: 500,
          ...RETRY_ENVELOPE,
        },
      );
      return { schedulerId, degraded: false };
    }

    if (spec.type === 'INTERVAL') {
      await this.queue.upsertJobScheduler(
        schedulerId,
        {
          every: spec.everyMs,
          startDate: spec.startAt,
          endDate: spec.endAt,
        },
        {
          name: 'run',
          data: { jobId: spec.jobId, trigger: 'SCHEDULE' },
          // Per-fire retry options belong under `opts` in the scheduler template;
          // at the top level they would silently no-op.
          opts: { ...RETRY_ENVELOPE },
        },
      );
      return { schedulerId, degraded: false };
    }

    // CRON
    await this.queue.upsertJobScheduler(
      schedulerId,
      {
        pattern: spec.pattern,
        tz: spec.tz,
        startDate: spec.startAt,
        endDate: spec.endAt,
      },
      {
        name: 'run',
        data: { jobId: spec.jobId, trigger: 'SCHEDULE' },
        // Per-fire retry options belong under `opts` in the scheduler template;
        // at the top level they would silently no-op.
        opts: { ...RETRY_ENVELOPE },
      },
    );
    return { schedulerId, degraded: false };
  }

  async removeSchedule(jobId: string): Promise<void> {
    if (!this.queue) {
      this.logger.warn(
        `scheduled-jobs.queue.degraded removeSchedule jobId=${jobId}`,
      );
      return;
    }

    const schedulerId = `sjob__${jobId}`;

    // Remove the repeatable scheduler (INTERVAL / CRON). No-op for one-shots.
    try {
      await this.queue.removeJobScheduler(schedulerId);
    } catch {
      // Scheduler may not exist — guard and continue
    }

    // Also remove the deterministic delayed job (ONE_SHOT). removeJobScheduler
    // does NOT touch a plain delayed job, so without this a pending one-shot
    // would be uncancellable.
    try {
      const job = await this.queue.getJob(schedulerId);
      if (job) {
        await job.remove();
      }
    } catch {
      // Job may not exist or already be locked/running — guard and continue
    }
  }

  async enqueueNow(
    jobId: string,
    trigger: 'MANUAL' | 'CATCHUP',
    customJobId?: string,
  ): Promise<{ id: string | null; degraded: boolean }> {
    if (!this.queue) {
      this.logger.warn(
        `scheduled-jobs.queue.degraded enqueueNow jobId=${jobId} trigger=${trigger}`,
      );
      return { id: null, degraded: true };
    }

    const job = await this.queue.add(
      'run',
      { jobId, trigger },
      {
        removeOnComplete: 250,
        removeOnFail: 500,
        ...RETRY_ENVELOPE,
        // Deterministic jobId (CATCHUP): BullMQ dedupes adds with the same
        // custom jobId so concurrent sweeps for the same tick enqueue once.
        // MANUAL run-now has no customJobId to always allow re-runs.
        ...(customJobId != null ? { jobId: customJobId } : {}),
      },
    );
    return { id: String(job.id), degraded: false };
  }

  isAvailable(): boolean {
    return Boolean(this.queue);
  }

  async onModuleDestroy() {
    await this.queue?.close();
  }
}
