import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as cronParser from 'cron-parser';
import { BullMqScheduledJobsQueuePort } from './queue/scheduled-jobs-queue.port';
import type { ScheduleSpec } from './queue/scheduled-job-data';
import { SandboxRunner, SandboxResult } from './sandbox/sandbox-runner';

export interface RegisterScheduledJobInput {
  jobId: string;
  scheduleType: 'INTERVAL' | 'CRON' | 'ONE_SHOT';
  intervalEvery?: number;
  intervalUnit?: 'MINUTES' | 'HOURS' | 'DAYS';
  cronExpression?: string;
  timezone?: string;
  startAt?: string | null;
  endAt?: string | null;
}

const UNIT_MS: Record<'MINUTES' | 'HOURS' | 'DAYS', number> = {
  MINUTES: 60_000,
  HOURS: 3_600_000,
  DAYS: 86_400_000,
};

@Injectable()
export class ScheduledJobsService {
  private readonly logger = new Logger(ScheduledJobsService.name);

  constructor(
    private readonly queue: BullMqScheduledJobsQueuePort,
    private readonly runner: SandboxRunner,
    private readonly configService: ConfigService,
  ) {}

  /**
   * Translates a RegisterScheduledJobInput into a ScheduleSpec and upserts it
   * into the queue. Returns the queue port result.
   */
  async register(
    input: RegisterScheduledJobInput,
  ): Promise<{ schedulerId: string | null; degraded: boolean }> {
    const spec = this.buildSpec(input);
    this.logger.log(
      `scheduled-jobs.register jobId=${input.jobId} type=${input.scheduleType}`,
    );
    return this.queue.upsertSchedule(spec);
  }

  async unregister(jobId: string): Promise<void> {
    this.logger.log(`scheduled-jobs.unregister jobId=${jobId}`);
    return this.queue.removeSchedule(jobId);
  }

  async runNow(
    jobId: string,
  ): Promise<{ id: string | null; degraded: boolean }> {
    this.logger.log(`scheduled-jobs.runNow jobId=${jobId}`);
    return this.queue.enqueueNow(jobId, 'MANUAL');
  }

  /**
   * Determines whether a job is due for execution relative to lastRunAt and
   * the schedule definition, then enqueues a CATCHUP job if so.
   *
   *   INTERVAL  – due if no lastRunAt OR (now - lastRunAt) >= everyMs
   *   CRON      – due if the most recent cron tick (via cron-parser, tz-aware)
   *               fell AFTER lastRunAt (i.e. a tick in (lastRunAt, now] not yet run)
   *   ONE_SHOT  – due if runAtMs <= now AND never ran (no lastRunAt)
   *
   * All branches short-circuit to not-due when outside the [startAt, endAt) window.
   *
   * CATCHUP jobs are enqueued with a deterministic jobId (`catchup__<id>__<tick>`)
   * so concurrent sweeps for the same due tick dedupe in BullMQ.
   */
  async runIfDue(
    input: RegisterScheduledJobInput & { lastRunAt?: string | null },
  ): Promise<{ enqueued: boolean }> {
    const now = Date.now();
    const lastRunMs = input.lastRunAt ? Date.parse(input.lastRunAt) : null;

    // Window check: applied to ALL schedule types before computing due-ness.
    if (input.startAt != null) {
      const startMs = Date.parse(input.startAt);
      if (!Number.isNaN(startMs) && now < startMs) {
        return { enqueued: false };
      }
    }
    if (input.endAt != null) {
      const endMs = Date.parse(input.endAt);
      if (!Number.isNaN(endMs) && now >= endMs) {
        return { enqueued: false };
      }
    }

    let due = false;
    // The tick timestamp used to build the deterministic CATCHUP jobId.
    // For INTERVAL we use `now` (last due boundary); for CRON the exact fire time.
    let dueTickMs: number = now;

    if (input.scheduleType === 'INTERVAL') {
      if (input.intervalEvery == null || !input.intervalUnit) {
        throw new BadRequestException(
          'INTERVAL schedule requires intervalEvery e intervalUnit',
        );
      }
      const everyMs = input.intervalEvery * UNIT_MS[input.intervalUnit];
      due = lastRunMs == null || now - lastRunMs >= everyMs;
      // Tick = floor to the nearest interval boundary for deterministic dedup
      dueTickMs = lastRunMs == null ? now : lastRunMs + everyMs;
    } else if (input.scheduleType === 'CRON') {
      if (!input.cronExpression) {
        throw new BadRequestException('CRON schedule requires cronExpression');
      }
      const tz = input.timezone ?? 'UTC';
      let prevFireMs: number;
      try {
        const parsed = cronParser.parseExpression(input.cronExpression, {
          currentDate: new Date(now),
          tz,
        });
        prevFireMs = parsed.prev().toDate().getTime();
      } catch {
        throw new BadRequestException(
          `Expressão cron inválida: ${input.cronExpression}`,
        );
      }
      // Due if the most recent fire time falls after lastRunAt (not yet run)
      due = prevFireMs > (lastRunMs ?? -Infinity);
      dueTickMs = prevFireMs;
    } else if (input.scheduleType === 'ONE_SHOT') {
      if (input.startAt == null) {
        throw new BadRequestException('ONE_SHOT schedule requires startAt');
      }
      const runAtMs = Date.parse(input.startAt);
      due = runAtMs <= now && lastRunMs == null;
      dueTickMs = runAtMs;
    }

    if (due) {
      // Deterministic CATCHUP jobId: BullMQ dedupes adds with the same custom
      // jobId so two overlapping sweeps for the same tick enqueue exactly once.
      // BullMQ forbids ':' in jobIds → use '__' delimiter (same convention as
      // src/automations/queue/bullmq-queue.port.ts:50).
      const catchupJobId = `catchup__${input.jobId}__${dueTickMs}`;
      this.logger.log(
        `scheduled-jobs.runIfDue CATCHUP jobId=${input.jobId} tick=${dueTickMs}`,
      );
      await this.queue.enqueueNow(input.jobId, 'CATCHUP', catchupJobId);
      return { enqueued: true };
    }

    return { enqueued: false };
  }

  /**
   * Upserts all provided job configs. Returns the count of successfully
   * registered jobs.
   */
  async reconcile(inputs: RegisterScheduledJobInput[]): Promise<number> {
    let count = 0;
    for (const input of inputs) {
      await this.register(input);
      count++;
    }
    return count;
  }

  async dryRun(body: {
    code: string;
    input?: unknown;
    writeMode?: 'safe' | 'real';
  }): Promise<SandboxResult> {
    const planfiBaseUrl = this.configService.get<string>(
      'SCHEDULED_JOBS_PLANFI_BASE_URL',
    );
    return this.runner.run({
      code: body.code,
      input: body.input,
      limits: { timeoutMs: 15000, memoryMb: 128 },
      planfi: planfiBaseUrl
        ? { baseUrl: planfiBaseUrl, writeMode: body.writeMode ?? 'safe' }
        : undefined,
    });
  }

  // ---------------------------------------------------------------------------
  // Private helpers
  // ---------------------------------------------------------------------------

  private buildSpec(input: RegisterScheduledJobInput): ScheduleSpec {
    const { jobId, scheduleType } = input;

    if (scheduleType === 'INTERVAL') {
      if (input.intervalEvery == null || !input.intervalUnit) {
        throw new BadRequestException(
          'INTERVAL schedule requires intervalEvery and intervalUnit',
        );
      }
      const everyMs = input.intervalEvery * UNIT_MS[input.intervalUnit];
      if (!Number.isFinite(everyMs) || everyMs <= 0) {
        throw new BadRequestException(
          'INTERVAL schedule requires a valid positive interval',
        );
      }
      const spec: ScheduleSpec = {
        jobId,
        type: 'INTERVAL',
        everyMs,
        ...(input.startAt != null
          ? { startAt: this.parseDate(input.startAt, 'startAt') }
          : {}),
        ...(input.endAt != null
          ? { endAt: this.parseDate(input.endAt, 'endAt') }
          : {}),
      };
      return spec;
    }

    if (scheduleType === 'CRON') {
      if (!input.cronExpression) {
        throw new BadRequestException('CRON schedule requires cronExpression');
      }
      const spec: ScheduleSpec = {
        jobId,
        type: 'CRON',
        pattern: input.cronExpression,
        tz: input.timezone ?? 'UTC',
        ...(input.startAt != null
          ? { startAt: this.parseDate(input.startAt, 'startAt') }
          : {}),
        ...(input.endAt != null
          ? { endAt: this.parseDate(input.endAt, 'endAt') }
          : {}),
      };
      return spec;
    }

    if (scheduleType === 'ONE_SHOT') {
      if (input.startAt == null) {
        throw new BadRequestException('ONE_SHOT schedule requires startAt');
      }
      const spec: ScheduleSpec = {
        jobId,
        type: 'ONE_SHOT',
        runAtMs: this.parseDate(input.startAt, 'startAt'),
      };
      return spec;
    }

    throw new BadRequestException(
      `Unknown scheduleType: ${scheduleType as string}`,
    );
  }

  /**
   * Defensive guard: parses an ISO date string into epoch ms, throwing a
   * BadRequestException instead of propagating NaN into the queue spec.
   */
  private parseDate(value: string, field: string): number {
    const ms = Date.parse(value);
    if (Number.isNaN(ms)) {
      throw new BadRequestException(`Invalid date for ${field}: ${value}`);
    }
    return ms;
  }
}
