import { describe, test, expect } from "bun:test";
import type { Logger } from "pino";
import { MemoryQueue } from "@/queue/memory-queue.js";
import type { JobPayload, JobHandler, JobOutcome } from "@/types/index.js";
import { FakeMetrics } from "../_helpers/fake-metrics.js";
import { METRIC } from "@/metrics/names.js";
import { QUEUE } from "@/core/constants.js";
import { JobAbortedError } from "@/core/errors.js";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

const noop = () => {};
const mockLogger = {
  info: noop,
  warn: noop,
  error: noop,
  debug: noop,
  trace: noop,
  fatal: noop,
  child: () => mockLogger,
} as unknown as Logger;

function makeSlackJob(channelId: string, messageTs: string): JobPayload {
  return {
    jobKind: "slack_mention",
    channelId,
    threadTs: messageTs,
    messageTs,
    userId: "U001",
    text: "hello",
    language: "en",
  };
}

function makeJiraJob(issueKey: string): JobPayload {
  return {
    jobKind: "jira",
    issueId: "10001",
    issueKey,
    summary: `Summary for ${issueKey}`,
    description: "desc",
  };
}

function makePrCommentJob(repoName: string, prNumber: number): JobPayload {
  return {
    jobKind: "pr_comment",
    repoName,
    prNumber,
    branch: "feature/x",
    commentId: 1,
    commentBody: "please fix",
    isReviewComment: false,
  };
}

/**
 * Create a handler that follows a pre-defined plan of outcomes.
 * "throw" causes the handler to throw an Error; any other string is returned as JobOutcome.
 */
function handlerSpy(plan: Array<"completed" | "needs_info" | "throw">) {
  let idx = 0;
  const calls: JobPayload[] = [];
  const handler: JobHandler = async (
    job: JobPayload,
    _signal: AbortSignal,
  ): Promise<JobOutcome> => {
    calls.push(job);
    const action = plan[idx++] ?? "completed";
    if (action === "throw") throw new Error("test failure");
    return action;
  };
  return { handler, calls };
}

/** Poll until `pred()` returns true, or throw after `timeoutMs`. */
async function waitUntil(pred: () => boolean, timeoutMs = 2000): Promise<void> {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    if (pred()) return;
    await new Promise((r) => setTimeout(r, 10));
  }
  throw new Error("waitUntil: timed out");
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("MemoryQueue", () => {
  // 1. enqueue + start + handler runs once (happy path)
  test("enqueue + start: handler is invoked for a single job", async () => {
    const queue = new MemoryQueue(mockLogger);
    const { handler, calls } = handlerSpy(["completed"]);

    queue.start(handler);
    await queue.enqueue(makeSlackJob("C1", "ts1"));

    await waitUntil(() => calls.length === 1);
    expect(calls).toHaveLength(1);
    expect(calls[0]!.jobKind).toBe("slack_mention");
    queue.stop();
  });

  // 2. Concurrency = 1: second job waits until first completes
  test("concurrency = 1: second job waits for first to finish", async () => {
    const queue = new MemoryQueue(mockLogger);
    const order: string[] = [];
    let resolveFirst: (() => void) | null = null;
    const firstBlocked = new Promise<void>((r) => {
      resolveFirst = r;
    });

    const handler: JobHandler = async (
      job: JobPayload,
      _signal: AbortSignal,
    ): Promise<JobOutcome> => {
      if (job.jobKind === "slack_mention" && job.channelId === "C1") {
        order.push("first-start");
        await firstBlocked;
        order.push("first-end");
      } else {
        order.push("second");
      }
      return "completed";
    };

    queue.start(handler);
    await queue.enqueue(makeSlackJob("C1", "ts1"));
    await queue.enqueue(makeSlackJob("C2", "ts2"));

    // Give time for the first job to start but the second should not run
    await new Promise((r) => setTimeout(r, 50));
    expect(order).toEqual(["first-start"]);

    resolveFirst!();
    await waitUntil(() => order.length === 3);
    expect(order).toEqual(["first-start", "first-end", "second"]);
    queue.stop();
  });

  // 3. pendingCount reflects queue state
  test("pendingCount: 0 before enqueue, increments, decrements after processing", async () => {
    const queue = new MemoryQueue(mockLogger);
    expect(queue.pendingCount()).toBe(0);

    let resolveHandler: (() => void) | null = null;
    const blocked = new Promise<void>((r) => {
      resolveHandler = r;
    });

    const handler: JobHandler = async (
      _job: JobPayload,
      _signal: AbortSignal,
    ): Promise<JobOutcome> => {
      await blocked;
      return "completed";
    };

    queue.start(handler);
    await queue.enqueue(makeSlackJob("C1", "ts1"));
    // First job starts immediately, popped from queue
    await new Promise((r) => setTimeout(r, 30));
    // The first job is in-flight, so pendingCount should be 0
    expect(queue.pendingCount()).toBe(0);

    await queue.enqueue(makeSlackJob("C2", "ts2"));
    expect(queue.pendingCount()).toBe(1);

    resolveHandler!();
    await waitUntil(() => queue.pendingCount() === 0 && !queue.isBusy());
    expect(queue.pendingCount()).toBe(0);
    queue.stop();
  });

  // 4. Handler returns "completed" -> job finished, no retry
  test("handler returning 'completed' finishes the job with no retry", async () => {
    const queue = new MemoryQueue(mockLogger);
    const { handler, calls } = handlerSpy(["completed"]);

    queue.start(handler);
    await queue.enqueue(makeSlackJob("C1", "ts1"));

    await waitUntil(() => calls.length === 1 && !queue.isBusy());
    expect(calls).toHaveLength(1);
    queue.stop();
  });

  // 5. Handler throws once, succeeds on retry -> total 2 invocations
  test("handler throws once then succeeds on retry: 2 total invocations", async () => {
    const queue = new MemoryQueue(mockLogger);
    const { handler, calls } = handlerSpy(["throw", "completed"]);

    queue.start(handler);
    await queue.enqueue(makeSlackJob("C1", "ts1"));

    await waitUntil(() => calls.length === 2 && !queue.isBusy());
    expect(calls).toHaveLength(2);
    // Both invocations are for the same job
    expect(calls[0]!.jobKind).toBe("slack_mention");
    expect(calls[1]!.jobKind).toBe("slack_mention");
    queue.stop();
  });

  // 6. Handler throws consistently -> MAX_RETRIES(2) exhausted, total 3 invocations
  test("handler throws consistently: 1 + MAX_RETRIES(2) = 3 total invocations", async () => {
    const queue = new MemoryQueue(mockLogger);
    const { handler, calls } = handlerSpy(["throw", "throw", "throw"]);

    queue.start(handler);
    await queue.enqueue(makeSlackJob("C1", "ts1"));

    await waitUntil(() => calls.length === 3 && !queue.isBusy());
    expect(calls).toHaveLength(3);
    queue.stop();
  });

  // 7. Handler returns "needs_info" -> job finishes, no retry (only throws trigger retry)
  test("handler returning 'needs_info' finishes without retry", async () => {
    const queue = new MemoryQueue(mockLogger);
    const { handler, calls } = handlerSpy(["needs_info"]);

    queue.start(handler);
    await queue.enqueue(makeSlackJob("C1", "ts1"));

    await waitUntil(() => calls.length === 1 && !queue.isBusy());
    expect(calls).toHaveLength(1);
    queue.stop();
  });

  // 8. Enqueue same key twice -> deduplicated
  test("enqueue same key twice: second enqueue is deduplicated", async () => {
    const queue = new MemoryQueue(mockLogger);
    let resolveHandler: (() => void) | null = null;
    const blocked = new Promise<void>((r) => {
      resolveHandler = r;
    });
    const calls: JobPayload[] = [];

    const handler: JobHandler = async (
      job: JobPayload,
      _signal: AbortSignal,
    ): Promise<JobOutcome> => {
      calls.push(job);
      if (calls.length === 1) await blocked;
      return "completed";
    };

    queue.start(handler);
    await queue.enqueue(makeSlackJob("C1", "ts1"));
    await new Promise((r) => setTimeout(r, 30));

    // Same key (slack:C1:ts1) — should be deduped because it's currently processing
    await queue.enqueue(makeSlackJob("C1", "ts1"));
    expect(queue.pendingCount()).toBe(0);

    resolveHandler!();
    await waitUntil(() => !queue.isBusy());
    expect(calls).toHaveLength(1);
    queue.stop();
  });

  // 8b. Dedup of pending (not in-flight) job
  test("enqueue same key twice while first is pending: deduplicated", async () => {
    const queue = new MemoryQueue(mockLogger);
    const { handler, calls } = handlerSpy(["completed", "completed"]);

    // Don't start yet — enqueue two with same key
    await queue.enqueue(makeSlackJob("C1", "ts1"));
    await queue.enqueue(makeSlackJob("C1", "ts1"));
    expect(queue.pendingCount()).toBe(1);

    queue.start(handler);
    await waitUntil(() => calls.length === 1 && !queue.isBusy());
    expect(calls).toHaveLength(1);
    queue.stop();
  });

  // 9. Multiple different jobKinds coexist
  test("multiple different jobKinds are processed correctly", async () => {
    const queue = new MemoryQueue(mockLogger);
    const calls: JobPayload[] = [];

    const handler: JobHandler = async (
      job: JobPayload,
      _signal: AbortSignal,
    ): Promise<JobOutcome> => {
      calls.push(job);
      return "completed";
    };

    queue.start(handler);
    await queue.enqueue(makeSlackJob("C1", "ts1"));
    await queue.enqueue(makeJiraJob("PROJ-1"));
    await queue.enqueue(makePrCommentJob("my-repo", 42));

    await waitUntil(() => calls.length === 3 && !queue.isBusy());
    expect(calls[0]!.jobKind).toBe("slack_mention");
    expect(calls[1]!.jobKind).toBe("jira");
    expect(calls[2]!.jobKind).toBe("pr_comment");
    queue.stop();
  });

  // 10. stop() during idle: subsequent enqueue is queued but not processed
  test("stop() during idle: enqueued jobs are not processed", async () => {
    const queue = new MemoryQueue(mockLogger);
    const { handler, calls } = handlerSpy(["completed"]);

    queue.start(handler);
    queue.stop();

    await queue.enqueue(makeSlackJob("C1", "ts1"));
    await new Promise((r) => setTimeout(r, 100));

    // Job is in the queue but not processed (handler is null after stop)
    expect(queue.pendingCount()).toBe(1);
    expect(calls).toHaveLength(0);
  });

  // 11. stop() with pending jobs: pending jobs remain but are not drained
  test("stop() with pending jobs: jobs remain in queue but processing halts", async () => {
    const queue = new MemoryQueue(mockLogger);
    let resolveHandler: (() => void) | null = null;
    const blocked = new Promise<void>((r) => {
      resolveHandler = r;
    });
    const calls: JobPayload[] = [];

    const handler: JobHandler = async (
      job: JobPayload,
      _signal: AbortSignal,
    ): Promise<JobOutcome> => {
      calls.push(job);
      if (calls.length === 1) await blocked;
      return "completed";
    };

    queue.start(handler);
    await queue.enqueue(makeSlackJob("C1", "ts1"));
    await queue.enqueue(makeSlackJob("C2", "ts2"));
    await queue.enqueue(makeSlackJob("C3", "ts3"));

    await new Promise((r) => setTimeout(r, 30));
    expect(calls).toHaveLength(1); // first job started

    queue.stop();
    // Pending jobs remain in the queue
    expect(queue.pendingCount()).toBe(2);

    resolveHandler!();
    await new Promise((r) => setTimeout(r, 100));

    // Second and third jobs never processed
    expect(calls).toHaveLength(1);
    // Pending jobs still there
    expect(queue.pendingCount()).toBe(2);
  });

  // 12. onHighLoad callback fires when queue exceeds threshold
  test("onHighLoad fires when pendingCount exceeds highLoadThreshold", async () => {
    const highLoadStates: Array<{ pendingCount: number }> = [];
    const queue = new MemoryQueue(mockLogger, {
      highLoadThreshold: 2,
      highLoadCooldownMs: 0,
      onHighLoad: (state) => {
        highLoadStates.push({ pendingCount: state.pendingCount });
      },
    });

    // Don't start — just enqueue to fill up
    await queue.enqueue(makeSlackJob("C1", "ts1"));
    await queue.enqueue(makeSlackJob("C2", "ts2"));
    // Threshold is 2, check is `> threshold`, so we need 3 pending
    expect(highLoadStates).toHaveLength(0);

    await queue.enqueue(makeSlackJob("C3", "ts3"));
    expect(highLoadStates).toHaveLength(1);
    expect(highLoadStates[0]!.pendingCount).toBe(3);
  });

  // 13. enqueue with payload preserves payload through to handler
  test("payload is preserved when passed to handler", async () => {
    const queue = new MemoryQueue(mockLogger);
    const received: JobPayload[] = [];

    const handler: JobHandler = async (
      job: JobPayload,
      _signal: AbortSignal,
    ): Promise<JobOutcome> => {
      received.push(job);
      return "completed";
    };

    const original = makePrCommentJob("my-repo", 99);
    queue.start(handler);
    await queue.enqueue(original);

    await waitUntil(() => received.length === 1);
    const job = received[0]!;
    expect(job.jobKind).toBe("pr_comment");
    if (job.jobKind === "pr_comment") {
      expect(job.repoName).toBe("my-repo");
      expect(job.prNumber).toBe(99);
      expect(job.commentBody).toBe("please fix");
    }
    queue.stop();
  });

  // 14. Both "completed" and "needs_info" outcomes are accepted without retry
  test("both 'completed' and 'needs_info' outcomes are accepted without retry", async () => {
    const queue = new MemoryQueue(mockLogger);
    const { handler, calls } = handlerSpy(["completed", "needs_info"]);

    queue.start(handler);
    await queue.enqueue(makeSlackJob("C1", "ts1"));
    await queue.enqueue(makeSlackJob("C2", "ts2"));

    await waitUntil(() => calls.length === 2 && !queue.isBusy());
    expect(calls).toHaveLength(2);
    queue.stop();
  });

  // 15. FIFO order: jobs A, B enqueued in order → handler sees A then B
  test("FIFO order: jobs are processed in enqueue order", async () => {
    const queue = new MemoryQueue(mockLogger);
    const order: string[] = [];

    const handler: JobHandler = async (
      job: JobPayload,
      _signal: AbortSignal,
    ): Promise<JobOutcome> => {
      if (job.jobKind === "jira") order.push(job.issueKey);
      return "completed";
    };

    queue.start(handler);
    await queue.enqueue(makeJiraJob("A-1"));
    await queue.enqueue(makeJiraJob("B-2"));
    await queue.enqueue(makeJiraJob("C-3"));

    await waitUntil(() => order.length === 3);
    expect(order).toEqual(["A-1", "B-2", "C-3"]);
    queue.stop();
  });

  // 16. Retry goes to front of queue (unshift), not back
  test("retried job goes to front of queue (ahead of other pending jobs)", async () => {
    const queue = new MemoryQueue(mockLogger);
    const order: string[] = [];
    let callCount = 0;
    let resolveFirst: (() => void) | null = null;
    const firstBlocked = new Promise<void>((r) => {
      resolveFirst = r;
    });

    const handler: JobHandler = async (
      job: JobPayload,
      _signal: AbortSignal,
    ): Promise<JobOutcome> => {
      callCount++;
      if (job.jobKind === "jira" && job.issueKey === "A-1" && callCount === 1) {
        // Block to allow B-2 to be enqueued
        await firstBlocked;
        throw new Error("fail A first time");
      }
      if (job.jobKind === "jira") order.push(job.issueKey);
      return "completed";
    };

    queue.start(handler);
    await queue.enqueue(makeJiraJob("A-1"));
    await new Promise((r) => setTimeout(r, 20));
    // A-1 is now in-flight and blocked. Enqueue B-2.
    await queue.enqueue(makeJiraJob("B-2"));

    resolveFirst!();
    // After A-1 throws, it should be unshifted to front (ahead of B-2)
    await waitUntil(() => order.length === 2 && !queue.isBusy());
    expect(order).toEqual(["A-1", "B-2"]);
    queue.stop();
  });

  // 17. start() after stop() resumes processing of remaining jobs
  test("start() after stop() resumes processing pending jobs", async () => {
    const queue = new MemoryQueue(mockLogger);
    const calls: JobPayload[] = [];

    const handler: JobHandler = async (
      job: JobPayload,
      _signal: AbortSignal,
    ): Promise<JobOutcome> => {
      calls.push(job);
      return "completed";
    };

    // Enqueue without starting
    await queue.enqueue(makeSlackJob("C1", "ts1"));
    await queue.enqueue(makeSlackJob("C2", "ts2"));
    expect(queue.pendingCount()).toBe(2);

    queue.start(handler);
    await waitUntil(() => calls.length === 2 && !queue.isBusy());
    expect(calls).toHaveLength(2);

    queue.stop();
    await queue.enqueue(makeSlackJob("C3", "ts3"));
    expect(queue.pendingCount()).toBe(1);

    // Re-start with new handler
    const calls2: JobPayload[] = [];
    const handler2: JobHandler = async (
      job: JobPayload,
      _signal: AbortSignal,
    ): Promise<JobOutcome> => {
      calls2.push(job);
      return "completed";
    };
    queue.start(handler2);
    await waitUntil(() => calls2.length === 1 && !queue.isBusy());
    expect(calls2).toHaveLength(1);
    queue.stop();
  });

  // NOTE: Timeout tests are skipped because JOB_TIMEOUT_MS (50 min) is imported
  // at module scope from constants with no DI seam to override it. Testing the
  // timeout path would require either waiting 50 real minutes or modifying the
  // source to accept a configurable timeout. Neither is appropriate here.
  // TODO: Add timeout path tests if a DI seam for JOB_TIMEOUT_MS is introduced.

  // 18. Metrics: enqueue + complete emits jobs.received and jobs.completed
  test("enqueue + handler completing emits jobs.received and jobs.completed", async () => {
    const metrics = new FakeMetrics();
    const queue = new MemoryQueue(mockLogger, { metrics });
    const { handler } = handlerSpy(["completed"]);

    queue.start(handler);
    await queue.enqueue(makeJiraJob("PROJ-1"));

    await waitUntil(() => !queue.isBusy());
    queue.stop();

    expect(metrics.countOf(METRIC.jobsReceived)).toBe(1);
    const receivedCall = metrics.last(METRIC.jobsReceived);
    expect(receivedCall!.tags).toEqual({ job_kind: "jira" });

    expect(metrics.countOf(METRIC.jobsCompleted)).toBe(1);
    const completedCall = metrics.last(METRIC.jobsCompleted);
    expect(completedCall!.tags).toEqual({ job_kind: "jira", outcome: "completed" });
  });

  // 19. Metrics: dedup emits jobs.deduplicated once
  test("enqueuing the same job key twice without starting emits jobs.deduplicated once", async () => {
    const metrics = new FakeMetrics();
    const queue = new MemoryQueue(mockLogger, { metrics });

    // Don't start — no handler, so drain() no-ops
    await queue.enqueue(makeJiraJob("PROJ-2"));
    await queue.enqueue(makeJiraJob("PROJ-2")); // duplicate

    expect(metrics.countOf(METRIC.jobsDeduplicated)).toBe(1);
    const dedupCall = metrics.last(METRIC.jobsDeduplicated);
    expect(dedupCall!.tags).toEqual({ job_kind: "jira" });
  });

  // 20. Metrics: jobs.retried emitted MAX_RETRIES times; aborted job emits jobs.completed{aborted}
  test("handler that always throws: jobs.retried=MAX_RETRIES and jobs.completed{aborted}", async () => {
    const metrics = new FakeMetrics();
    const queue = new MemoryQueue(mockLogger, { metrics });
    const { handler } = handlerSpy(["throw", "throw", "throw"]);

    queue.start(handler);
    await queue.enqueue(makeJiraJob("PROJ-3"));

    await waitUntil(() => !queue.isBusy() && metrics.countOf(METRIC.jobsCompleted) >= 1);
    queue.stop();

    expect(metrics.countOf(METRIC.jobsRetried)).toBe(QUEUE.MAX_RETRIES);
    const abortedTags = metrics.tagsOf(METRIC.jobsCompleted);
    expect(abortedTags).toContainEqual({ job_kind: "jira", outcome: "aborted" });
  });

  // 20b. Core abort contract: JobAbortedError ⇒ NOT retried and NO terminal aborted metric
  test("aborted job (JobAbortedError) is not retried and emits no terminal aborted metric", async () => {
    const metrics = new FakeMetrics();
    const queue = new MemoryQueue(mockLogger, { metrics });
    let calls = 0;
    queue.start(async () => {
      calls++;
      throw new JobAbortedError("aborted for shutdown");
    });

    await queue.enqueue(makeJiraJob("ABORT-1"));
    await waitUntil(() => calls >= 1 && !queue.isBusy());
    queue.stop();

    expect(calls).toBe(1); // handler ran exactly once — no retry
    expect(metrics.countOf(METRIC.jobsRetried)).toBe(0);
    const completedTags = metrics.tagsOf(METRIC.jobsCompleted);
    expect(completedTags).not.toContainEqual({ job_kind: "jira", outcome: "aborted" });
  });

  // 21. Metrics: queue.high_load emitted when pending count exceeds highLoadThreshold
  test("queue.high_load metric is emitted when pending count exceeds highLoadThreshold", async () => {
    const metrics = new FakeMetrics();
    const queue = new MemoryQueue(mockLogger, {
      metrics,
      onHighLoad: () => {},
      highLoadThreshold: 2,
      highLoadCooldownMs: 0,
    });

    // Don't start — enqueue 3 distinct keys (threshold=2, check is > threshold)
    await queue.enqueue(makeJiraJob("HIGH-1"));
    await queue.enqueue(makeJiraJob("HIGH-2"));
    await queue.enqueue(makeJiraJob("HIGH-3"));

    expect(metrics.countOf(METRIC.queueHighLoad)).toBeGreaterThanOrEqual(1);
  });

  // 22. Metrics: sampler gauges emitted with correct values when queue is idle
  test("sample() emits queue.depth, queue.busy, queue.current_job_age gauges; busy=0 when idle", async () => {
    const metrics = new FakeMetrics();
    const queue = new MemoryQueue(mockLogger, { metrics });

    // Invoke the private sample() directly without needing the interval to fire
    (queue as unknown as { sample: () => void }).sample();

    expect(metrics.countOf(METRIC.queueDepth)).toBe(1);
    expect(metrics.countOf(METRIC.queueBusy)).toBe(1);
    expect(metrics.countOf(METRIC.queueCurrentJobAge)).toBe(1);

    // When idle, busy gauge must be 0
    expect(metrics.last(METRIC.queueBusy)!.value).toBe(0);
  });
});

// ---------------------------------------------------------------------------
// MemoryQueue.shutdown
// ---------------------------------------------------------------------------

describe("MemoryQueue.shutdown", () => {
  test("waits for a job that finishes within the grace window (no abort)", async () => {
    const q = new MemoryQueue(mockLogger);
    let sawAbort = false;
    q.start(async (_job: JobPayload, signal: AbortSignal) => {
      await new Promise((r) => setTimeout(r, 20));
      sawAbort = signal.aborted;
      return "completed" as const;
    });
    await q.enqueue(makeJiraJob("SQAI-1"));
    await q.shutdown(1000);
    expect(sawAbort).toBe(false);
    expect(q.isBusy()).toBe(false);
  });

  test("aborts a job that exceeds the grace window", async () => {
    const q = new MemoryQueue(mockLogger);
    let abortedDuringRun = false;
    q.start(async (_job: JobPayload, signal: AbortSignal) => {
      await new Promise<void>((resolve) => {
        signal.addEventListener("abort", () => {
          abortedDuringRun = true;
          resolve();
        });
      });
      return "completed" as const;
    });
    await q.enqueue(makeJiraJob("SQAI-2"));
    await q.shutdown(30);
    expect(abortedDuringRun).toBe(true);
  });
});
