// CF Workers queue shim — replaces libs/queue/index.ts
//
// Architecture (mirrors Blocklet Server's original design):
//
// D1 jobs table = scheduler (source of truth for all jobs)
// CF Queue     = executor   (replaces fastq — concurrent execution engine)
// Cron         = dispatcher (replaces loop() — polls D1 for due jobs)
//
// Flow:
//   push(job, immediate) → D1 addJob + CF Queue send → consumer executes → D1 deleteJob
//   push(job, delayed)   → D1 addJob only → cron dispatches when due → CF Queue → execute
//   pushAndWait(job)     → D1 addJob + inline execute (caller awaits result)
//   cancel/delete        → D1 deleteJob (delayed jobs never reach Queue until due)
//   CF Queue 429         → job stays in D1, cron retries next cycle
//
// This preserves the original Blocklet Server semantics:
//   fastq.push(job)  → CF Queue send (immediate execution with concurrency control)
//   store.addJob()   → D1 jobs table (persistence + scheduling)
//   loop() polling   → cron every minute (dispatches due delayed jobs)

import EventEmitter from 'events';
import { nanoid } from 'nanoid';
import { Op } from 'sequelize';

import { Job, TJob } from '../../api/src/store/models/job';
import createQueueStore from '../../api/src/libs/queue/store';

// --- CF Queue binding ---
let _cfQueue: any | null = null;

export function setCFQueue(queue: any) {
  _cfQueue = queue;
}

// --- Handler registry ---
type RegisteredHandler = {
  onJob: (job: any) => Promise<any>;
  executeJob: (jobId: string, job: any, persist: boolean) => Promise<any>;
  store: ReturnType<typeof createQueueStore>;
  cancel: (id: string) => Promise<any>;
};
const _handlers = new Map<string, RegisteredHandler>();

export function getHandler(queueName: string): RegisteredHandler | undefined {
  return _handlers.get(queueName);
}

export function getAllHandlerNames(): string[] {
  return Array.from(_handlers.keys());
}

// --- waitUntil for keeping async work alive ---
let _waitUntil: ((promise: Promise<any>) => void) | null = null;

export function setWaitUntil(fn: (promise: Promise<any>) => void) {
  _waitUntil = fn;
}

// Pending promises — await before returning response
const _pendingPushJobs: Promise<any>[] = [];
(globalThis as any).__cfPendingJobs__ = _pendingPushJobs;

// --- Request-time timer tracking ---
// Business code uses setTimeout for batching (e.g. addToBatch's 3-second window).
// In CF Workers, these timers fire after the response is sent — too late.
// We intercept setTimeout during request handling, and flush pending timer
// callbacks in flushPendingJobs before the response returns.

type TrackedTimer = { fn: Function; args: any[]; cleared: boolean; realId: any };
const _trackedTimers: TrackedTimer[] = [];

const _prevSetTimeout = globalThis.setTimeout;
const _prevClearTimeout = globalThis.clearTimeout;

(globalThis as any).setTimeout = function(fn: any, delay?: number, ...args: any[]) {
  if (typeof fn !== 'function') {
    return (_prevSetTimeout as any)(fn, delay, ...args);
  }

  const entry: TrackedTimer = { fn, args, cleared: false, realId: null };

  entry.realId = (_prevSetTimeout as any)((...a: any[]) => {
    if (!entry.cleared) {
      entry.cleared = true;
      fn(...a);
    }
  }, delay, ...args);

  _trackedTimers.push(entry);
  return entry.realId;
};

(globalThis as any).clearTimeout = function(id: any) {
  for (const entry of _trackedTimers) {
    if (entry.realId === id || (entry.realId && typeof entry.realId === 'object' &&
        id && typeof id === 'object' && entry.realId[Symbol.toPrimitive]?.() === id[Symbol.toPrimitive]?.())) {
      entry.cleared = true;
    }
  }
  return (_prevClearTimeout as any)(id);
};

function flushTrackedTimers() {
  const pending = _trackedTimers.splice(0);

  const origSetHas = Set.prototype.has;
  if (_cfQueue) {
    Set.prototype.has = function() { return false; };
  }

  for (const entry of pending) {
    if (!entry.cleared) {
      entry.cleared = true;
      try { (_prevClearTimeout as any)(entry.realId); } catch (_e) { /* ignore */ }
      try { entry.fn(...entry.args); } catch (e) { console.error('[queue] timer flush error:', e); }
    }
  }

  if (_cfQueue) {
    Set.prototype.has = origSetHas;
  }
}

export async function flushPendingJobs() {
  const MAX_ITERATIONS = 10;
  for (let i = 0; i < MAX_ITERATIONS; i++) {
    flushTrackedTimers();

    if (_pendingPushJobs.length === 0) break;
    const batch = _pendingPushJobs.splice(0);
    await Promise.allSettled(batch);
  }
}

// --- Scheduled jobs: dispatch due jobs from D1 to CF Queue ---
type ScheduledQueueEntry = {
  store: ReturnType<typeof createQueueStore>;
  executeJob: (jobId: string, job: any, persist: boolean) => Promise<any>;
  cancel: (id: string) => Promise<any>;
  name: string;
};
const _scheduledQueues = new Map<string, ScheduledQueueEntry>();

/**
 * Dispatch all due delayed jobs from D1 to CF Queue for execution.
 * Called from CF Cron Trigger (every minute).
 *
 * This replaces the original loop() that polled every 4 seconds.
 * Jobs with will_run_at <= now are picked up, cancelled in D1 (to prevent
 * re-dispatch next cycle), then sent to CF Queue for execution.
 * If CF Queue is unavailable, execute inline as fallback.
 */
export async function runAllScheduledJobs(): Promise<{ dispatched: number; failed: number; queues: string[] }> {
  let totalDispatched = 0;
  let totalFailed = 0;
  const queuesProcessed: string[] = [];

  // Dispatch helper: send job to CF Queue, or execute inline if unavailable
  //
  // Aligns with the original Blocklet Server semantics: the scheduler removes the
  // D1 row BEFORE onJob runs, so fire-and-forget re-pushes inside onJob (e.g.
  // handlePayment → paymentQueue.push({ runAt }) after a payment failure) can
  // freely INSERT a new scheduled row with the same id without hitting the
  // store.addJob duplicate check.
  //
  // The message carries persist:false so the consumer does NOT delete the row
  // again after onJob completes — otherwise it would wipe out the freshly
  // re-scheduled row inserted by the running handler.
  const dispatchJob = async (
    queueName: string,
    entry: { executeJob: Function; cancel: Function; store: ReturnType<typeof createQueueStore> },
    jobRecord: any,
  ) => {
    // Delete the old row up-front. Match the original loop() invariant:
    // once a scheduled row is picked up, it should no longer exist as a
    // "pending scheduled" row in D1 — the handler is now responsible for
    // its fate (success → nothing to clean up; failure → re-push writes a
    // brand new row).
    try {
      await entry.store.deleteJob(jobRecord.id);
    } catch (_e) {
      /* ignore — best effort */
    }

    if (_cfQueue) {
      try {
        await _cfQueue.send({ queueName, jobId: jobRecord.id, job: jobRecord.job, persist: false });
      } catch (sendErr: any) {
        // CF Queue unavailable → run inline with persist:false
        // (row was already deleted above; don't try to delete again)
        await entry.executeJob(jobRecord.id, jobRecord.job, false);
      }
    } else {
      await entry.executeJob(jobRecord.id, jobRecord.job, false);
    }
  };

  // Single aggregated D1 query across ALL registered queues. Replaces the
  // previous per-queue loop (16 queues × 2 queries = 32 serial round-trips,
  // ~2s wall time per scheduled tick) that consistently tripped CF Workers'
  // scheduled event time budget and produced ~1,440 exceededCpu outcomes/day.
  //
  // Shape: one findAll covering
  //   (a) delayed jobs that are due  — delay != -1 AND will_run_at <= now
  //   (b) immediate jobs stuck in D1 — delay == -1 AND created_at < now-2min
  // Results are grouped by queue name and dispatched via the existing
  // per-queue dispatchJob path, preserving original behavior.
  const queueNames = Array.from(_handlers.keys());
  if (queueNames.length === 0) {
    return { dispatched: 0, failed: 0, queues: [] };
  }

  const now = Date.now();
  // SQLite / D1 stores DATE columns as ISO strings. The raw D1 bind layer
  // rejects Date objects with "Type 'object' not supported", so we pass the
  // serialized string here instead of a native Date.
  const twoMinAgoISO = new Date(now - 2 * 60 * 1000).toISOString();

  let allJobs: TJob[] = [];
  try {
    allJobs = (await Job.findAll({
      where: {
        queue: { [Op.in]: queueNames },
        cancelled: false,
        [Op.or]: [
          { delay: { [Op.not]: -1 }, will_run_at: { [Op.lte]: now } },
          { delay: -1, created_at: { [Op.lt]: twoMinAgoISO } },
        ],
      },
      order: [['created_at', 'ASC']],
      transaction: null,
    })) as unknown as TJob[];
  } catch (err: any) {
    console.error('[queue:dispatch] Aggregated D1 scan failed:', err?.message || err);
    return { dispatched: 0, failed: 0, queues: [] };
  }

  if (allJobs.length === 0) {
    return { dispatched: 0, failed: 0, queues: [] };
  }

  // Group by queue, preserving created_at ASC order from the SQL ORDER BY.
  const jobsByQueue = new Map<string, TJob[]>();
  for (const job of allJobs) {
    if (!job.job || !job.id) continue;
    const arr = jobsByQueue.get(job.queue) ?? [];
    arr.push(job);
    jobsByQueue.set(job.queue, arr);
  }

  for (const [queueName, jobs] of jobsByQueue) {
    const entry = _handlers.get(queueName);
    if (!entry || jobs.length === 0) continue;

    queuesProcessed.push(queueName);

    for (const jobRecord of jobs) {
      try {
        await dispatchJob(queueName, entry, jobRecord);
        totalDispatched++;
      } catch (err: any) {
        totalFailed++;
        console.error(`[queue:dispatch] Failed ${queueName}/${jobRecord.id}:`, err?.message || err);
      }
    }
  }

  return { dispatched: totalDispatched, failed: totalFailed, queues: queuesProcessed };
}

// --- Queue factory ---

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;
  runAt?: number;
  skipDuplicateCheck?: boolean;
};

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();

  // Execute the job directly (used by pushAndWait and queue consumer)
  const executeJob = async (jobId: string, job: T, persist: boolean): Promise<{ id: string; job: T; result: any }> => {
    let retryCount = 0;

    // eslint-disable-next-line no-constant-condition
    while (true) {
      try {
        console.log(`[queue:${name}] executing job`, jobId);
        const result = await onJob(job);
        console.log(`[queue:${name}] job finished`, jobId);

        if (persist) {
          try { await store.deleteJob(jobId); } catch (_e) { /* ignore */ }
        }

        return { id: jobId, job, result };
      } catch (err: any) {
        console.error(`[queue:${name}] job error`, jobId, err?.message || err);

        if (err?.nonRetryable === true) {
          if (persist) {
            try { await store.deleteJob(jobId); } catch (_e) { /* ignore */ }
          }
          throw err;
        }

        retryCount++;
        if (retryCount >= maxRetries!) {
          if (persist) {
            try { await store.deleteJob(jobId); } catch (_e) { /* ignore */ }
          }
          throw err;
        }

        console.log(`[queue:${name}] retrying job`, jobId, `attempt ${retryCount}/${maxRetries}`);
        queueEvents.emit('retry', { id: jobId, job });
        if (retryDelay && retryDelay > 0) {
          await new Promise((r) => (_prevSetTimeout as any)(r, retryDelay));
        }
      }
    }
  };

  // Send job to CF Queue for execution (replaces fastq.push)
  const sendToCFQueue = async (jobId: string, job: T) => {
    const message = { queueName: name, jobId, job };
    await _cfQueue.send(message);
  };

  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);
    };

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

    const jobId = getJobId(id, job);

    // Calculate delay in seconds
    let delaySeconds = 0;
    if (delay) {
      delaySeconds = delay;
    } else if (runAt) {
      delaySeconds = Math.max(0, Math.floor((runAt as number) - Date.now() / 1000));
    }

    const isImmediate = delaySeconds <= 0;

    const enqueue = async () => {
      if (isImmediate) {
        // === Immediate job ===
        // 1. Persist to D1 (source of truth)
        // 2. Send to CF Queue for execution (like fastq.push)
        // 3. If CF Queue fails → job stays in D1, cron will dispatch
        if (persist) {
          try {
            await store.addJob(jobId, job, {}, skipDuplicateCheck);
          } catch (err: any) {
            const isDuplicate = err?.code === 'JOB_DUPLICATE' ||
              err?.message?.includes('UNIQUE constraint failed') ||
              err?.message?.includes('SQLITE_CONSTRAINT');
            if (isDuplicate) {
              return;
            }
            console.error(`[queue:${name}] addJob error`, err?.message);
            return;
          }
        }

        emit('queued', { id: jobId, job, persist });

        if (_cfQueue) {
          try {
            await sendToCFQueue(jobId, job);
          } catch (err: any) {
            // CF Queue unavailable (429 Too Many Requests, transport down, etc.)
            // Fall through to inline execution: the worker context already has
            // its own CPU budget (HTTP handler: 30s; scheduled handler: 30s),
            // and a tightly-throttled CF Queue + a backed-up cron dispatcher
            // means immediate jobs would otherwise sit in D1 indefinitely.
            // The D1 row persists either way, so a crash here still lets a
            // later cron tick (or a retry) pick it up.
            console.warn(`[queue:${name}] CF Queue send failed (${err?.message || err}); executing inline`);
            try {
              // Pass persist=false so executeJob does NOT delete the D1 row on a
              // terminal failure — that would wipe the only durable backup (esp.
              // for maxRetries:0 queues like webhookQueue) and the cron dispatcher
              // could never retry it. Delete the row ONLY on success here.
              // (PR #1381 re-review P1.)
              const data = await executeJob(jobId, job, false);
              if (persist) {
                try {
                  await store.deleteJob(jobId);
                } catch (_e) {
                  /* ignore — duplicate delete is harmless */
                }
              }
              emit('finished', data);
            } catch (execErr: any) {
              // D1 row intact → a later cron tick / retry can pick it up.
              emit('failed', { id: jobId, job, error: execErr });
            }
          }
        } else {
          // No CF Queue binding — execute inline (Blocklet Server compatibility)
          try {
            const data = await executeJob(jobId, job, persist);
            emit('finished', data);
          } catch (err: any) {
            emit('failed', { id: jobId, job, error: err });
          }
        }
      } else {
        // === Delayed job ===
        // Only persist to D1 with will_run_at. Cron dispatches when due.
        // NOT sent to CF Queue — allows cancel/replace via D1 delete.
        if (persist) {
          const attrs: any = { delay: delaySeconds, will_run_at: Date.now() + delaySeconds * 1000 };
          try {
            await store.addJob(jobId, job, attrs, skipDuplicateCheck);
          } catch (err: any) {
            const isDuplicate = err?.code === 'JOB_DUPLICATE' ||
              err?.message?.includes('UNIQUE constraint failed') ||
              err?.message?.includes('SQLITE_CONSTRAINT');
            if (isDuplicate) {
              return;
            }
            console.error(`[queue:${name}] addJob error for delayed job`, err?.message);
            return;
          }
        }
        emit('queued', { id: jobId, job, persist });
      }
    };

    const promise = enqueue()
      .catch((err) => console.error(`[queue:${name}] push error:`, err?.message || err));

    // Register promise to keep Worker alive
    const isHttp = (globalThis as any).__cfHttpContext__;
    if (isHttp && _waitUntil) {
      _waitUntil(promise);
    } else {
      _pendingPushJobs.push(promise);
      if (_waitUntil) {
        _waitUntil(promise);
      }
    }

    (jobEvents as any).id = jobId;
    return jobEvents;
  };

  // pushAndWait always runs inline (caller needs the result synchronously)
  const pushAndWait = async (params: PushParams<T>) => {
    if (!params.job) {
      throw new Error('Can not queue empty job');
    }

    const jobId = getJobId(params.id, params.job);
    const persist = params.persist !== false;

    if (persist) {
      try {
        await store.addJob(jobId, params.job, {}, params.skipDuplicateCheck || false);
      } catch (err: any) {
        const isDuplicate = err?.code === 'JOB_DUPLICATE' ||
          err?.message?.includes('UNIQUE constraint failed') ||
          err?.message?.includes('SQLITE_CONSTRAINT');
        if (isDuplicate) {
          console.log(`[queue:${name}] pushAndWait duplicate job`, jobId);
        } else {
          throw err;
        }
      }
    }

    try {
      const data = await executeJob(jobId, params.job, persist);
      queueEvents.emit('finished', data);
      return data;
    } catch (err: any) {
      queueEvents.emit('failed', { id: jobId, job: params.job, error: err });
      throw { id: jobId, job: params.job, error: err };
    }
  };

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

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

  const deleteJob = async (id: string, knownExists: boolean = false): Promise<boolean> => {
    if (!knownExists) {
      const exists = await getJob(id);
      if (!exists) return false;
    }
    try {
      await store.deleteJob(id);
      return true;
    } catch (_e) {
      try { await cancel(id); } catch (_e2) { /* ignore */ }
      return false;
    }
  };

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

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

  // Register handler for CF Queue consumer dispatch
  _handlers.set(name, { onJob, executeJob, store, cancel });

  // Register for scheduled job dispatch (cron picks up due delayed jobs)
  if (enableScheduledJob) {
    _scheduledQueues.set(name, { store, executeJob, cancel, name });
  }

  return queueInstance;
}

// Exported for unit tests; not part of the public queue API.
export const __test__ = {
  resetHandlers() {
    _handlers.clear();
    _scheduledQueues.clear();
    _pendingPushJobs.length = 0;
    _trackedTimers.length = 0;
  },
  registerForTest(name: string, overrides: Partial<RegisteredHandler>) {
    const noop = async () => undefined;
    const store = createQueueStore(name);
    _handlers.set(name, {
      onJob: overrides.onJob ?? noop,
      executeJob: overrides.executeJob ?? (noop as any),
      cancel: overrides.cancel ?? (noop as any),
      store: overrides.store ?? store,
    });
  },
};
