import { describe, it, expect, vi, beforeEach } from "vitest";
import {
  validateKeyword,
  previewKeywordMatches,
  applyKeywordWithReassignment,
  KEYWORD_MAX_LENGTH,
} from "./categorizationService";

vi.mock("./db", () => {
  const getDb = vi.fn();
  return {
    getDb,
    // Tests don't exercise the real lock; withTransaction just runs the
    // callback with whatever db the test wired getDb to resolve, so every
    // existing BEGIN/COMMIT/ROLLBACK ordering assertion still holds (the
    // service issues them on the mock db inside the callback).
    withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
  };
});

import { getDb } from "./db";

const mockSelect = vi.fn();
const mockExecute = vi.fn();
const mockDb = { select: mockSelect, execute: mockExecute };

beforeEach(() => {
  vi.mocked(getDb).mockResolvedValue(mockDb as never);
  mockSelect.mockReset();
  mockExecute.mockReset();
});

describe("validateKeyword", () => {
  it("rejects whitespace-only input", () => {
    expect(validateKeyword("   ")).toEqual({ ok: false, reason: "tooShort" });
  });

  it("rejects single-character keywords", () => {
    expect(validateKeyword("a")).toEqual({ ok: false, reason: "tooShort" });
  });

  it("accepts a minimal two-character keyword after trim", () => {
    expect(validateKeyword(" ab ")).toEqual({ ok: true, value: "ab" });
  });

  it("rejects keywords longer than 64 characters (ReDoS cap)", () => {
    const long = "a".repeat(KEYWORD_MAX_LENGTH + 1);
    expect(validateKeyword(long)).toEqual({ ok: false, reason: "tooLong" });
  });

  it("accepts keywords at exactly 64 characters", () => {
    const borderline = "a".repeat(KEYWORD_MAX_LENGTH);
    expect(validateKeyword(borderline)).toEqual({ ok: true, value: borderline });
  });
});

describe("previewKeywordMatches", () => {
  it("binds the LIKE pattern as a parameter (no interpolation)", async () => {
    mockSelect.mockResolvedValueOnce([]);

    await previewKeywordMatches("METRO");

    expect(mockSelect).toHaveBeenCalledTimes(1);
    const sql = mockSelect.mock.calls[0][0] as string;
    const params = mockSelect.mock.calls[0][1] as unknown[];
    expect(sql).toContain("LIKE $1");
    expect(sql).not.toContain("'metro'");
    expect(sql).not.toContain("'%metro%'");
    expect(params[0]).toBe("%metro%");
  });

  it("returns an empty preview when the keyword is invalid", async () => {
    const result = await previewKeywordMatches("a");
    expect(result).toEqual({ visible: [], totalMatches: 0 });
    expect(mockSelect).not.toHaveBeenCalled();
  });

  it("filters candidates with the regex and respects the visible cap", async () => {
    // 3 rows: 2 true matches, 1 substring-only (should be dropped by word-boundary)
    mockSelect.mockResolvedValueOnce([
      { id: 1, date: "2026-03-15", description: "METRO #123", amount: -45, category_name: null, category_color: null },
      { id: 2, date: "2026-03-02", description: "METRO PLUS", amount: -67.2, category_name: null, category_color: null },
      { id: 3, date: "2026-02-18", description: "METROPOLITAIN", amount: -12, category_name: null, category_color: null },
    ]);

    const result = await previewKeywordMatches("METRO", 2);

    expect(result.totalMatches).toBe(2);
    expect(result.visible).toHaveLength(2);
    expect(result.visible[0].id).toBe(1);
    expect(result.visible[1].id).toBe(2);
  });
});

describe("applyKeywordWithReassignment", () => {
  it("wraps INSERT + UPDATEs in a BEGIN/COMMIT transaction", async () => {
    mockSelect.mockResolvedValueOnce([]); // existing keyword lookup → none
    mockExecute.mockResolvedValue({ lastInsertId: 77, rowsAffected: 1 });

    await applyKeywordWithReassignment({
      keyword: "METRO",
      categoryId: 3,
      transactionIds: [1, 2],
      allowReplaceExisting: false,
    });

    const commands = mockExecute.mock.calls.map((c) => (c[0] as string).trim());
    expect(commands[0]).toBe("BEGIN");
    expect(commands[commands.length - 1]).toBe("COMMIT");
    expect(commands.filter((c) => c.startsWith("INSERT INTO keywords"))).toHaveLength(1);
    expect(commands.filter((c) => c.startsWith("UPDATE transactions"))).toHaveLength(2);
  });

  it("rolls back when an UPDATE throws", async () => {
    mockSelect.mockResolvedValueOnce([]);
    mockExecute
      .mockResolvedValueOnce(undefined) // BEGIN
      .mockResolvedValueOnce({ lastInsertId: 77 }) // INSERT keywords
      .mockRejectedValueOnce(new Error("disk full")); // UPDATE transactions fails

    await expect(
      applyKeywordWithReassignment({
        keyword: "METRO",
        categoryId: 3,
        transactionIds: [1],
        allowReplaceExisting: false,
      }),
    ).rejects.toThrow("disk full");

    const commands = mockExecute.mock.calls.map((c) => (c[0] as string).trim());
    expect(commands).toContain("BEGIN");
    expect(commands).toContain("ROLLBACK");
    expect(commands).not.toContain("COMMIT");
  });

  it("blocks reassignment when keyword exists for another category without allowReplaceExisting", async () => {
    mockSelect.mockResolvedValueOnce([{ id: 10, category_id: 5 }]);

    await expect(
      applyKeywordWithReassignment({
        keyword: "METRO",
        categoryId: 3,
        transactionIds: [1],
        allowReplaceExisting: false,
      }),
    ).rejects.toThrow("keyword_already_exists");

    const commands = mockExecute.mock.calls.map((c) => (c[0] as string).trim());
    expect(commands).toContain("BEGIN");
    expect(commands).toContain("ROLLBACK");
  });

  it("reassigns existing keyword when allowReplaceExisting is true", async () => {
    mockSelect.mockResolvedValueOnce([{ id: 10, category_id: 5 }]);
    mockExecute.mockResolvedValue({ rowsAffected: 1 });

    const result = await applyKeywordWithReassignment({
      keyword: "METRO",
      categoryId: 3,
      transactionIds: [1, 2],
      allowReplaceExisting: true,
    });

    expect(result.replacedExisting).toBe(true);
    expect(result.updatedTransactions).toBe(2);
    const commands = mockExecute.mock.calls.map((c) => (c[0] as string).trim());
    expect(commands.some((c) => c.startsWith("UPDATE keywords SET category_id"))).toBe(true);
    expect(commands).toContain("COMMIT");
  });

  it("rejects invalid keywords before touching the database", async () => {
    await expect(
      applyKeywordWithReassignment({
        keyword: "a",
        categoryId: 3,
        transactionIds: [1],
        allowReplaceExisting: false,
      }),
    ).rejects.toThrow("invalid_keyword:tooShort");
    expect(mockExecute).not.toHaveBeenCalled();
  });
});
