import { describe, it, expect, vi, beforeEach } from "vitest";

const mockProducer = vi.hoisted(() => ({ send: vi.fn() }));

vi.mock("../../config/kafka.js", () => ({
  producer: mockProducer,
}));

vi.mock("../observability/logger.js", () => ({
  logger: {
    info: vi.fn(),
    error: vi.fn(),
    warn: vi.fn(),
    debug: vi.fn(),
  },
}));

vi.mock("./outbox.repository.js", () => ({
  outboxRepository: {
    findPending: vi.fn(),
    markPublished: vi.fn(),
    incrementRetry: vi.fn(),
    markFailed: vi.fn(),
    create: vi.fn(),
  },
}));

import { OutboxPublisher } from "./outbox.publisher.js";
import { outboxRepository } from "./outbox.repository.js";
import type { OutboxMessage } from "./outbox.schema.js";

function createMessage(overrides: Partial<OutboxMessage> = {}): OutboxMessage {
  return {
    id: "msg-1",
    aggregateType: "Chapter",
    aggregateId: "ch-1",
    eventType: "chapter.created",
    topic: "edu.content.chapter.events",
    payload: '{"event_id":"e1"}',
    status: "pending",
    retryCount: 0,
    createdAt: new Date(),
    publishedAt: null,
    nextRetryAt: null,
    lastError: null,
    ...overrides,
  };
}

describe("OutboxPublisher", () => {
  let publisher: OutboxPublisher;

  beforeEach(() => {
    vi.clearAllMocks();
    publisher = new OutboxPublisher();
  });

  describe("dispatch (via poll)", () => {
    it("should send message to kafka and mark as published", async () => {
      const message = createMessage();
      vi.mocked(outboxRepository.findPending).mockResolvedValue([message]);
      mockProducer.send.mockResolvedValue(undefined);

      // Access private poll via casting
      await (publisher as unknown as { poll: () => Promise<void> }).poll();

      expect(mockProducer.send).toHaveBeenCalledWith({
        topic: message.topic,
        messages: [
          {
            key: message.aggregateId,
            value: message.payload,
            headers: {
              eventId: message.id,
              eventType: message.eventType,
              aggregateType: message.aggregateType,
            },
          },
        ],
      });
      expect(outboxRepository.markPublished).toHaveBeenCalledWith(message.id);
    });

    it("should increment retry when send fails and below max retries", async () => {
      const message = createMessage({ id: "msg-2", retryCount: 0 });
      vi.mocked(outboxRepository.findPending).mockResolvedValue([message]);
      mockProducer.send.mockRejectedValue(new Error("kafka down"));

      await (publisher as unknown as { poll: () => Promise<void> }).poll();

      expect(outboxRepository.incrementRetry).toHaveBeenCalledWith(
        "msg-2",
        "kafka down",
      );
      expect(outboxRepository.markFailed).not.toHaveBeenCalled();
    });

    it("should mark failed when retry count reaches max", async () => {
      const message = createMessage({ id: "msg-3", retryCount: 4 });
      vi.mocked(outboxRepository.findPending).mockResolvedValue([message]);
      mockProducer.send.mockRejectedValue(new Error("kafka down"));

      await (publisher as unknown as { poll: () => Promise<void> }).poll();

      expect(outboxRepository.markFailed).toHaveBeenCalledWith(
        "msg-3",
        "kafka down",
      );
      expect(outboxRepository.incrementRetry).not.toHaveBeenCalled();
    });

    it("should handle non-Error rejection in dispatch", async () => {
      const message = createMessage({ id: "msg-4", retryCount: 0 });
      vi.mocked(outboxRepository.findPending).mockResolvedValue([message]);
      mockProducer.send.mockRejectedValue("string error");

      await (publisher as unknown as { poll: () => Promise<void> }).poll();

      expect(outboxRepository.incrementRetry).toHaveBeenCalledWith(
        "msg-4",
        "string error",
      );
    });
  });

  describe("poll error handling", () => {
    it("should swallow errors from findPending", async () => {
      vi.mocked(outboxRepository.findPending).mockRejectedValue(
        new Error("db error"),
      );
      await expect(
        (publisher as unknown as { poll: () => Promise<void> }).poll(),
      ).resolves.not.toThrow();
    });

    it("should process multiple messages in a batch", async () => {
      const messages = [
        createMessage({ id: "m1" }),
        createMessage({ id: "m2" }),
      ];
      vi.mocked(outboxRepository.findPending).mockResolvedValue(messages);
      mockProducer.send.mockResolvedValue(undefined);

      await (publisher as unknown as { poll: () => Promise<void> }).poll();

      expect(mockProducer.send).toHaveBeenCalledTimes(2);
      expect(outboxRepository.markPublished).toHaveBeenCalledWith("m1");
      expect(outboxRepository.markPublished).toHaveBeenCalledWith("m2");
    });
  });

  describe("start/stop", () => {
    it("should start and stop without error", async () => {
      await publisher.start();
      await publisher.stop();
      // Should not throw
    });

    it("should stop without error when not started", async () => {
      await publisher.stop();
    });
  });
});
