import { TRPCError } from "@trpc/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

// ---------------------------------------------------------------------------
// Hoisted mocks – must be declared before any imports that depend on them
// ---------------------------------------------------------------------------

const { assertCommentEntityAccess, createNotification, createAuditEntry } = vi.hoisted(() => ({
  assertCommentEntityAccess: vi.fn(),
  createNotification: vi.fn().mockResolvedValue({}),
  createAuditEntry: vi.fn(),
}));

vi.mock("../lib/comment-entity-registry.js", async (importOriginal) => {
  const actual = await importOriginal<typeof import("../lib/comment-entity-registry.js")>();
  return { ...actual, assertCommentEntityAccess };
});

vi.mock("../lib/create-notification.js", () => ({ createNotification }));

vi.mock("../lib/audit.js", () => ({ createAuditEntry }));

// stripHtml is a pure transform – pass through to keep assertions readable
vi.mock("../lib/strip-html.js", () => ({ stripHtml: (s: string) => s }));

// comment-support helpers – mock the ones with non-trivial side-effects
const {
  parseCommentMentions,
  commentBelongsToEntity,
  assertCommentManageableByActor,
  buildCommentCreateData,
} = vi.hoisted(() => ({
  parseCommentMentions: vi.fn().mockReturnValue([]),
  commentBelongsToEntity: vi.fn().mockReturnValue(true),
  assertCommentManageableByActor: vi.fn(),
  buildCommentCreateData: vi.fn(
    (input: {
      entityType: string;
      entityId: string;
      parentId?: string;
      authorId: string;
      body: string;
      mentions: string[];
    }) => ({
      entityType: input.entityType,
      entityId: input.entityId,
      ...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
      authorId: input.authorId,
      body: input.body,
      mentions: input.mentions,
    }),
  ),
}));

vi.mock("../router/comment-support.js", async (importOriginal) => {
  const actual = await importOriginal<typeof import("../router/comment-support.js")>();
  return {
    ...actual,
    parseCommentMentions,
    commentBelongsToEntity,
    assertCommentManageableByActor,
    buildCommentCreateData,
  };
});

// ---------------------------------------------------------------------------
// Imports – must come AFTER vi.mock calls
// ---------------------------------------------------------------------------

import {
  countComments,
  createComment,
  deleteComment,
  listCommentMentionCandidates,
  listComments,
  resolveComment,
} from "../router/comment-procedure-support.js";

// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------

function makeDb(overrides: Record<string, unknown> = {}) {
  return {
    comment: {
      findMany: vi.fn().mockResolvedValue([]),
      findUnique: vi.fn().mockResolvedValue({
        id: "comment_1",
        authorId: "user_1",
        entityType: "estimate",
        entityId: "est_1",
      }),
      count: vi.fn().mockResolvedValue(3),
      create: vi.fn().mockResolvedValue({
        id: "comment_1",
        body: "Hello",
        author: { id: "user_1", name: "Alice", email: "alice@example.com", image: null },
      }),
      update: vi.fn().mockResolvedValue({
        id: "comment_1",
        resolved: true,
        author: { id: "user_1", name: "Alice", email: "alice@example.com", image: null },
      }),
      deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
      delete: vi.fn().mockResolvedValue(undefined),
      ...overrides,
    },
  };
}

function makeCtx(
  dbOverrides: Record<string, unknown> = {},
  ctxOverrides: Record<string, unknown> = {},
) {
  return {
    db: makeDb(dbOverrides),
    dbUser: { id: "user_1", systemRole: "ADMIN" },
    roleDefaults: null,
    ...ctxOverrides,
  };
}

const ENTITY_INPUT = { entityType: "estimate" as const, entityId: "est_1" };

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

describe("comment router – procedure support", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    assertCommentEntityAccess.mockResolvedValue({
      buildLink: (_id: string) => "/estimates/est_1?tab=comments",
      listMentionCandidates: vi.fn().mockResolvedValue([]),
    });
    commentBelongsToEntity.mockReturnValue(true);
    assertCommentManageableByActor.mockReturnValue(undefined);
    parseCommentMentions.mockReturnValue([]);
    createNotification.mockResolvedValue({});
    createAuditEntry.mockResolvedValue(undefined);
  });

  // -------------------------------------------------------------------------
  // listComments
  // -------------------------------------------------------------------------

  describe("listComments", () => {
    it("queries with parentId:null and orders by createdAt asc", async () => {
      const ctx = makeCtx();
      await listComments(ctx as never, ENTITY_INPUT);

      expect(ctx.db.comment.findMany).toHaveBeenCalledWith(
        expect.objectContaining({
          where: { entityType: "estimate", entityId: "est_1", parentId: null },
          orderBy: { createdAt: "asc" },
        }),
      );
    });

    it("calls assertCommentEntityAccess before touching the database", async () => {
      const ctx = makeCtx();
      const callOrder: string[] = [];
      assertCommentEntityAccess.mockImplementation(async () => {
        callOrder.push("access");
        return { buildLink: () => "/link", listMentionCandidates: vi.fn() };
      });
      (ctx.db.comment.findMany as ReturnType<typeof vi.fn>).mockImplementation(async () => {
        callOrder.push("db");
        return [];
      });

      await listComments(ctx as never, ENTITY_INPUT);

      expect(callOrder).toEqual(["access", "db"]);
    });
  });

  // -------------------------------------------------------------------------
  // countComments
  // -------------------------------------------------------------------------

  describe("countComments", () => {
    it("returns the count from db.comment.count", async () => {
      const ctx = makeCtx();
      const result = await countComments(ctx as never, ENTITY_INPUT);
      expect(result).toBe(3);
    });

    it("calls assertCommentEntityAccess with entityType and entityId", async () => {
      const ctx = makeCtx();
      await countComments(ctx as never, ENTITY_INPUT);
      expect(assertCommentEntityAccess).toHaveBeenCalledWith(ctx, "estimate", "est_1");
    });
  });

  // -------------------------------------------------------------------------
  // listCommentMentionCandidates
  // -------------------------------------------------------------------------

  describe("listCommentMentionCandidates", () => {
    it("delegates to policy.listMentionCandidates with the normalised query", async () => {
      const listMentionCandidates = vi
        .fn()
        .mockResolvedValue([{ id: "user_2", name: "Bob", email: "bob@example.com" }]);
      assertCommentEntityAccess.mockResolvedValue({
        buildLink: () => "/link",
        listMentionCandidates,
      });
      const ctx = makeCtx();

      const result = await listCommentMentionCandidates(ctx as never, {
        ...ENTITY_INPUT,
        query: "bo",
      });

      expect(result).toEqual([{ id: "user_2", name: "Bob", email: "bob@example.com" }]);
      expect(listMentionCandidates).toHaveBeenCalledWith(ctx, "est_1", "bo");
    });

    it("passes undefined when query is an empty string", async () => {
      const listMentionCandidates = vi.fn().mockResolvedValue([]);
      assertCommentEntityAccess.mockResolvedValue({
        buildLink: () => "/link",
        listMentionCandidates,
      });
      const ctx = makeCtx();

      await listCommentMentionCandidates(ctx as never, { ...ENTITY_INPUT, query: "" });

      expect(listMentionCandidates).toHaveBeenCalledWith(ctx, "est_1", undefined);
    });
  });

  // -------------------------------------------------------------------------
  // createComment
  // -------------------------------------------------------------------------

  describe("createComment", () => {
    it("creates a comment with the correct data shape", async () => {
      const ctx = makeCtx();
      const result = await createComment(ctx as never, {
        ...ENTITY_INPUT,
        body: "Nice work",
      });

      expect(result.id).toBe("comment_1");
      expect(ctx.db.comment.create).toHaveBeenCalledWith(
        expect.objectContaining({
          data: expect.objectContaining({
            entityType: "estimate",
            entityId: "est_1",
            authorId: "user_1",
            body: "Nice work",
            mentions: [],
          }),
        }),
      );
    });

    it("throws UNAUTHORIZED when dbUser is absent", async () => {
      const ctx = makeCtx({}, { dbUser: null });

      await expect(
        createComment(ctx as never, { ...ENTITY_INPUT, body: "Hello" }),
      ).rejects.toThrowError(new TRPCError({ code: "UNAUTHORIZED" }));

      expect(ctx.db.comment.create).not.toHaveBeenCalled();
    });

    it("throws NOT_FOUND when parentId references a missing comment", async () => {
      const ctx = makeCtx({ findUnique: vi.fn().mockResolvedValue(null) });

      await expect(
        createComment(ctx as never, {
          ...ENTITY_INPUT,
          parentId: "comment_missing",
          body: "Reply",
        }),
      ).rejects.toThrowError(
        new TRPCError({ code: "NOT_FOUND", message: "Parent comment not found" }),
      );

      expect(ctx.db.comment.create).not.toHaveBeenCalled();
    });

    it("throws BAD_REQUEST when parent comment belongs to a different entity", async () => {
      commentBelongsToEntity.mockReturnValue(false);
      const ctx = makeCtx({
        findUnique: vi.fn().mockResolvedValue({
          id: "comment_parent",
          entityType: "estimate",
          entityId: "est_other",
        }),
      });

      await expect(
        createComment(ctx as never, {
          ...ENTITY_INPUT,
          parentId: "comment_parent",
          body: "Reply",
        }),
      ).rejects.toThrowError(
        new TRPCError({
          code: "BAD_REQUEST",
          message: "Parent comment does not belong to the requested entity",
        }),
      );

      expect(ctx.db.comment.create).not.toHaveBeenCalled();
    });
  });

  // -------------------------------------------------------------------------
  // resolveComment
  // -------------------------------------------------------------------------

  describe("resolveComment", () => {
    it("resolves a comment by setting resolved:true", async () => {
      const ctx = makeCtx();
      const result = await resolveComment(ctx as never, { id: "comment_1", resolved: true });

      expect(result.resolved).toBe(true);
      expect(ctx.db.comment.update).toHaveBeenCalledWith(
        expect.objectContaining({
          where: { id: "comment_1" },
          data: { resolved: true },
        }),
      );
    });

    it("unresolves a comment by setting resolved:false", async () => {
      makeCtx().db.comment.update as ReturnType<typeof vi.fn>;
      const ctx = makeCtx({
        update: vi.fn().mockResolvedValue({
          id: "comment_1",
          resolved: false,
          author: { id: "user_1", name: "Alice", email: "alice@example.com", image: null },
        }),
      });

      const result = await resolveComment(ctx as never, { id: "comment_1", resolved: false });

      expect(result.resolved).toBe(false);
      expect(ctx.db.comment.update).toHaveBeenCalledWith(
        expect.objectContaining({ data: { resolved: false } }),
      );
    });

    it("throws NOT_FOUND when comment does not exist", async () => {
      const ctx = makeCtx({ findUnique: vi.fn().mockResolvedValue(null) });

      await expect(
        resolveComment(ctx as never, { id: "comment_missing", resolved: true }),
      ).rejects.toThrowError(new TRPCError({ code: "NOT_FOUND", message: "Comment not found" }));

      expect(ctx.db.comment.update).not.toHaveBeenCalled();
    });
  });

  // -------------------------------------------------------------------------
  // deleteComment
  // -------------------------------------------------------------------------

  describe("deleteComment", () => {
    it("deletes children via deleteMany then the comment itself", async () => {
      const ctx = makeCtx();
      await deleteComment(ctx as never, { id: "comment_1" });

      expect(ctx.db.comment.deleteMany).toHaveBeenCalledWith(
        expect.objectContaining({ where: { parentId: "comment_1" } }),
      );
      expect(ctx.db.comment.delete).toHaveBeenCalledWith(
        expect.objectContaining({ where: { id: "comment_1" } }),
      );
    });

    it("throws UNAUTHORIZED when dbUser is absent", async () => {
      const ctx = makeCtx({}, { dbUser: null });

      await expect(deleteComment(ctx as never, { id: "comment_1" })).rejects.toThrowError(
        new TRPCError({ code: "UNAUTHORIZED" }),
      );

      expect(ctx.db.comment.delete).not.toHaveBeenCalled();
    });

    it("throws NOT_FOUND when comment does not exist", async () => {
      const ctx = makeCtx({ findUnique: vi.fn().mockResolvedValue(null) });

      await expect(deleteComment(ctx as never, { id: "comment_missing" })).rejects.toThrowError(
        new TRPCError({ code: "NOT_FOUND", message: "Comment not found" }),
      );

      expect(ctx.db.comment.delete).not.toHaveBeenCalled();
      expect(ctx.db.comment.deleteMany).not.toHaveBeenCalled();
    });
  });
});
