/* eslint-disable no-await-in-loop */
/* eslint-disable no-return-assign */
import EventEmitter from 'events';

import fastq from 'fastq';
import { nanoid } from 'nanoid';

import { AsyncLocalStorage } from 'async_hooks';
import logger from '../logger';
import { sleep, tryWithTimeout } from '../util';
import dayjs from '../dayjs';
import createQueueStore from './store';
import { Job } from '../../store/models/job';
import { sequelize } from '../../store/sequelize';

const CANCELLED = '__CANCELLED__';
const MIN_DELAY = process.env.NODE_ENV === 'test' ? 2 : 8;

type QueueOptions<T> = {
  id?: (job: T) => string;
  concurrency?: number;
  maxRetries?: number;
  maxTimeout?: number;
  retryDelay?: number;
  enableScheduledJob?: boolean;
};

type QueueParams<T> = {
  name: string;
  onJob: (job: T) => Promise<any>;
  options?: QueueOptions<T>;
};

const defaults: QueueOptions<any> = {
  concurrency: 1,
  maxRetries: 1,
  maxTimeout: 24 * 60 * 60 * 1000,
  retryDelay: 0,
  enableScheduledJob: false,
};

type PushParams<T> = {
  job: T;
  id?: string;
  persist?: boolean;
  delay?: number; // in seconds
  runAt?: number; // unix timestamp in seconds
  skipDuplicateCheck?: boolean; // Q1: skip addJob's findOne when caller guarantees no duplicate
};

export default function createQueue<T = any>({ name, onJob, options = defaults }: QueueParams<T>) {
  const store = createQueueStore(name);
  const { concurrency, maxRetries, maxTimeout, retryDelay, enableScheduledJob } = Object.assign({}, defaults, options);

  const getJobId = (id: string | undefined, job: any) => id || (options.id ? options.id(job) : nanoid()) || nanoid();

  const queueEvents = new EventEmitter();
  const asyncLocalStorage = new AsyncLocalStorage();
  const runningJobs = new Set();
  const cancelledJobs = new Set();

  const ensureUniqueExecution = async (id: string, fn: () => Promise<any>) => {
    if (runningJobs.has(id)) {
      logger.warn('Job already running, skipping', { id });
      return;
    }

    runningJobs.add(id);
    try {
      await asyncLocalStorage.run({ id }, fn);
    } finally {
      runningJobs.delete(id);
      logger.info(`Running job deleted: ${id}`);
    }
  };

  const queue = fastq(async ({ job, id, persist }: { job: any; id: string; persist: boolean }, cb: Function) => {
    logger.info('execute job', { id, job, persist });
    await ensureUniqueExecution(id, async () => {
      if (persist) {
        try {
          const cancelled = await store.isCancelled(id);
          if (cancelled) {
            cb(null, CANCELLED);
            return;
          }
        } catch (err) {
          cb(err);
          return;
        }
      }

      if (cancelledJobs.has(id)) {
        cb(null, CANCELLED);
        logger.info(`Job skipped because it is cancelled: ${id}`);
        cancelledJobs.delete(id);
        return;
      }

      try {
        const result = await tryWithTimeout(() => onJob(job), maxTimeout);
        logger.info('job finished', { id, result });
        cb(null, result);
      } catch (err) {
        cb(err);
      }
    });
    // @ts-ignore
  }, concurrency);

  const push = ({ job, id, persist = true, delay, runAt, skipDuplicateCheck = false }: PushParams<T>) => {
    const jobEvents = new EventEmitter();
    const emit = (e: string, data: any) => {
      queueEvents.emit(e, data);
      jobEvents.emit(e, data);
    };
    const now = dayjs().unix();

    if (!job) {
      throw new Error('Can not queue empty job');
    }

    const jobId = getJobId(id, job);

    if ((delay && delay >= MIN_DELAY) || (runAt && runAt > now)) {
      if (!enableScheduledJob) {
        throw new Error('Must set options.enableScheduledJob to true to run delay jobs');
      }

      // 这里不是精确的 delay, 延迟的时间太短没有意义，所以这里限制了最小 delay
      if (delay && delay < MIN_DELAY) {
        throw new Error(`minimum delay is ${MIN_DELAY}s`);
      }

      const attrs: { delay?: number; will_run_at?: number } = {};
      if (delay) {
        attrs.delay = delay;
        attrs.will_run_at = Date.now() + delay * 1000;
      }
      if (runAt) {
        attrs.delay = 1;
        attrs.will_run_at = runAt * 1000;
      }

      store
        .addJob(jobId, job, attrs, skipDuplicateCheck)
        .then(() => {
          emit('queued', { id: jobId, job, attrs, persist });
          logger.info('delayed or scheduled job queued', { id: jobId, job, attrs, persist });
        })
        .catch((err) => {
          console.error(err);
        });

      // @ts-ignore
      jobEvents.id = jobId;
      return jobEvents;
    }

    // eslint-disable-next-line @typescript-eslint/no-shadow
    const clearJob = (id: string) => store.deleteJob(id);

    const onJobComplete = async (err: any, result: any) => {
      if (result === CANCELLED) {
        await clearJob(jobId);
        emit('cancelled', { id: jobId, job });
        return;
      }

      if (!err) {
        await clearJob(jobId);
        emit('finished', { id: jobId, job, result });
        return;
      }
      logger.error('job errored', { queue: name, id: jobId, job, result, error: err });

      try {
        const doc = await store.getJob(jobId);
        if (!doc) {
          emit('failed', { id: jobId, job, error: err });
          return;
        }

        // Check if error is marked as non-retryable
        if (err && err.nonRetryable === true) {
          logger.info('fail job due to non-retryable error', { id: jobId, job, errorCode: err.code });
          await clearJob(jobId);
          emit('failed', { id: jobId, job, error: err });
          return;
        }

        // @ts-ignore
        if (doc.retry_count >= maxRetries) {
          logger.info('fail job on max retry exceed', { id: jobId, job });
          await clearJob(jobId);
          emit('failed', { id: jobId, job, error: err });
          return;
        }

        await store.updateJob(jobId, { retry_count: doc.retry_count + 1 });
        logger.info('retry job', { id: jobId, count: doc.retry_count + 1 });
        setTimeout(() => {
          emit('retry', { id: jobId, job, doc });
          queue.unshift({ id: jobId, job, persist }, onJobComplete);
        }, retryDelay);
        // eslint-disable-next-line no-shadow, @typescript-eslint/no-shadow
      } catch (err: any) {
        console.error(err);
        await clearJob(jobId);
        emit('failed', { id: jobId, job, error: err });
      }
    };

    const queueJob = () =>
      setImmediate(() => {
        emit('queued', { id: jobId, job, persist });
        logger.info('queue job', { id: jobId, job });
        if (cancelledJobs.has(jobId)) {
          cancelledJobs.delete(jobId);
        }
        queue.push({ id: jobId, job, persist }, onJobComplete);
      });

    if (persist) {
      store
        .addJob(jobId, job, {}, skipDuplicateCheck)
        .then(queueJob)
        .catch((err) => {
          logger.error('Can not add job to store', { error: err });
        });
    } else {
      queueJob();
    }

    // @ts-ignore
    jobEvents.id = jobId;
    return jobEvents;
  };

  const pushAndWait = (params: PushParams<T>) =>
    new Promise((resolve, reject) => {
      try {
        if (params.runAt || params.delay) {
          console.warn('You may have to wait for a long time to get the result of the delayed job');
        }

        const job = push(params);
        job.on('finished', (data: { id: string; job: T; result: any }) => resolve(data));
        job.on('canceled', (data: { id: string; job: T }) => resolve(data));
        job.on('failed', (data: { id: string; job: T; error: Error }) => reject(data));
      } catch (err) {
        reject(err);
      }
    });

  const cancel = async (id: string) => {
    const doc = await store.updateJob(id, { cancelled: true });
    cancelledJobs.add(id);
    return doc ? doc.job : null;
  };

  const getJob = async (id: string) => {
    const doc = await store.getJob(id);
    return doc ? doc.job : null;
  };

  // Q1: Accept knownExists flag to skip redundant getJob read when caller already checked
  const deleteJob = async (id: string, knownExists: boolean = false): Promise<boolean> => {
    if (!knownExists) {
      const exists = await getJob(id);
      if (!exists) {
        return false;
      }
    }
    try {
      // Q1: Try direct delete first (1 DB op). If delete fails, fall back to cancel
      // so the job is at least marked cancelled=true in DB for crash recovery.
      cancelledJobs.add(id);
      await store.deleteJob(id);
      logger.info(`Job deleted successfully: ${id}`);
      return true;
    } catch (error) {
      // Delete failed — ensure job is marked cancelled in DB as fallback
      try {
        await cancel(id);
      } catch (cancelError) {
        console.error(`Error during job cancellation fallback for ${id}:`, cancelError);
      }
      console.error(`Error during job deletion process for ${id}:`, error);
      return false;
    }
  };

  const updateJob = async (id: string, updates: any) => {
    const updatedJob = await store.updateJob(id, updates);
    return updatedJob;
  };

  // Populate the queue on startup
  process.nextTick(async () => {
    try {
      if (!Job.isInitialized()) {
        Job.initialize(sequelize);
      }
      const jobs = await store.getJobs();
      jobs.forEach((x) => {
        if (x.job && x.id) {
          push({ job: x.job, id: x.id, persist: false });
        } else {
          logger.info('skip invalid job from db', { job: x });
        }
      });
      // eslint-disable-next-line no-shadow
    } catch (err) {
      console.error(err);
      logger.error(`Can not load existing ${name} jobs`, { error: err });
    }
  });

  const loop = async () => {
    if (enableScheduledJob === true) {
      // eslint-disable-next-line no-constant-condition
      while (true) {
        await sleep((MIN_DELAY * 1000) / 2);

        try {
          /* eslint-disable no-await-in-loop */
          const jobs = await store.getScheduledJobs();
          for (const x of jobs) {
            if (x.job && x.id) {
              // fix: https://github.com/blocklet/payment-kit/issues/287
              await cancel(x.id);
              logger.info('reschedule delayed or scheduled job', {
                id: x.id,
                job: x.job,
              });
              push({ job: x.job, id: x.id, persist: false });
            } else {
              logger.info('skip invalid job from db', { job: x });
            }
          }
        } catch (err) {
          console.error(err);
          logger.error(`Can not load scheduled ${name} jobs`, { error: err });
        }
      }
    }
  };

  loop();

  return Object.assign(queueEvents, {
    store,
    push,
    pushAndWait,
    drain: (cb: any) => (queue.drain = cb),
    empty: (cb: any) => (queue.empty = cb),
    saturated: (cb: any) => (queue.saturated = cb),
    error: (cb: any) => (queue.error = cb),
    get: getJob,
    delete: deleteJob,
    cancel,
    update: updateJob,
    options: {
      concurrency,
      maxRetries,
      maxTimeout,
      retryDelay,
      enableScheduledJob,
    },
  });
}
