/**
 * Regression-style tests parameterised on both v2 (current seed) and v1
 * (new IPC taxonomy) category ids. These exercise the same app services on
 * both shapes and assert identical observable behaviour — the spec's
 * guarantee that the migration does not silently break:
 *
 *  - categorizationService (keyword → regex → category_id matching)
 *  - budgetService.getBudgetVsActualData (parent/child aggregation)
 *  - dataExportService envelope round-trip (SREF format parity)
 *
 * We mock the DB per test and drive each service with a small, deterministic
 * dataset scoped to either v2 or v1 category ids.
 */

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

vi.mock("../services/db", () => {
  const getDb = vi.fn();
  return {
    getDb,
    withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
  };
});

import { getDb } from "../services/db";
import {
  normalizeDescription,
  buildKeywordRegex,
  compileKeywords,
  categorizeBatch,
} from "../services/categorizationService";
import { getBudgetVsActualData } from "../services/budgetService";
import {
  parseImportedJson,
  serializeToJson,
  type ExportEnvelope,
} from "../services/dataExportService";
import type { Keyword, Category, BudgetEntry } from "../shared/types";

// ---------------------------------------------------------------------------
// Shared mock DB harness — each test resets and stubs specific SELECTs.
// ---------------------------------------------------------------------------

const mockSelect = vi.fn();
const mockExecute = vi.fn();

beforeEach(() => {
  vi.mocked(getDb).mockResolvedValue({ select: mockSelect, execute: mockExecute } as never);
  mockSelect.mockReset();
  mockExecute.mockReset();
});

// ---------------------------------------------------------------------------
// normalizeDescription — identical on v2/v1 inputs (no schema dependency)
// ---------------------------------------------------------------------------

describe("regression: normalizeDescription is schema-agnostic", () => {
  it.each([
    ["IGA #5555", "iga #5555"],
    ["SHELL\t#231", "shell #231"],
    ["  Hydro-Québec  FACTURE ", "hydro-quebec facture"],
  ])("%s -> %s", (input, expected) => {
    expect(normalizeDescription(input)).toBe(expected);
  });
});

describe("regression: buildKeywordRegex boundaries", () => {
  it("matches whole-word keywords on both v2- and v1-style descriptions", () => {
    const re = buildKeywordRegex(normalizeDescription("STM"));
    expect(re.test(normalizeDescription("STM CARTE OPUS"))).toBe(true);
    expect(re.test(normalizeDescription("METROSTMONTREAL"))).toBe(false);
  });
  it("handles a keyword with a non-word leading char (common in bank exports)", () => {
    const re = buildKeywordRegex(normalizeDescription("[INTERAC]"));
    expect(re.test(normalizeDescription("PAIEMENT [INTERAC] XXX"))).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// categorizeBatch parameterised per schema — same description must map to the
// same (schema-specific) category id.
// ---------------------------------------------------------------------------

type Schema = "v2" | "v1";

function kwFixture(schema: Schema): Keyword[] {
  const shellCat = schema === "v2" ? 40 : 1512;
  const igaCat = schema === "v2" ? 22 : 1111;
  const stmCat = schema === "v2" ? 28 : 1521;
  return [
    {
      id: 1,
      keyword: "SHELL",
      category_id: shellCat,
      priority: 100,
      is_active: true,
    } as Keyword,
    {
      id: 2,
      keyword: "IGA",
      category_id: igaCat,
      priority: 100,
      is_active: true,
    } as Keyword,
    {
      id: 3,
      keyword: "STM",
      category_id: stmCat,
      priority: 100,
      is_active: true,
    } as Keyword,
  ];
}

describe.each<[Schema]>([["v2"], ["v1"]])(
  "regression: categorizeBatch [%s]",
  (schema) => {
    it("matches the expected category ids for each description", async () => {
      mockSelect.mockResolvedValueOnce(kwFixture(schema));
      const results = await categorizeBatch([
        "SHELL #231 LAVAL",
        "IGA EXTRA #5555",
        "STM CARTE OPUS",
        "UNRELATED",
      ]);
      const [shell, iga, stm, none] = results;
      expect(shell.category_id).toBe(schema === "v2" ? 40 : 1512);
      expect(iga.category_id).toBe(schema === "v2" ? 22 : 1111);
      expect(stm.category_id).toBe(schema === "v2" ? 28 : 1521);
      expect(none.category_id).toBeNull();
    });
  },
);

describe("regression: compileKeywords parity across schemas", () => {
  it("produces identical regex patterns regardless of the category_id", () => {
    const v2Kw = kwFixture("v2");
    const v1Kw = kwFixture("v1");
    const v2Compiled = compileKeywords(v2Kw);
    const v1Compiled = compileKeywords(v1Kw);
    expect(v2Compiled.map((c) => c.regex.source)).toEqual(
      v1Compiled.map((c) => c.regex.source),
    );
  });
});

// ---------------------------------------------------------------------------
// budgetService.getBudgetVsActualData parameterised per schema
// ---------------------------------------------------------------------------

function budgetFixture(schema: Schema): {
  categories: Category[];
  entries: BudgetEntry[];
} {
  if (schema === "v2") {
    const categories: Category[] = [
      // Parent + 2 children to exercise aggregation
      mkCat(22, "Épicerie", null, "expense"),
      mkCat(220, "Épicerie courante", 22, "expense"),
      mkCat(221, "Gros achats", 22, "expense"),
    ];
    const entries: BudgetEntry[] = [
      mkEntry(220, 2026, 3, 400),
      mkEntry(221, 2026, 3, 200),
    ];
    return { categories, entries };
  }
  // v1: Épicerie → 1110 (subcategory Alimentation), 1111 + 1112 leaves
  const categories: Category[] = [
    mkCat(1100, "Alimentation", null, "expense"),
    mkCat(1110, "Épicerie", 1100, "expense"),
    mkCat(1111, "Régulière", 1110, "expense"),
    mkCat(1112, "Bio / spécialisée", 1110, "expense"),
  ];
  const entries: BudgetEntry[] = [
    mkEntry(1111, 2026, 3, 400),
    mkEntry(1112, 2026, 3, 200),
  ];
  return { categories, entries };
}

function mkCat(
  id: number,
  name: string,
  parent_id: number | null,
  type: "expense" | "income" | "transfer",
): Category {
  return {
    id,
    name,
    parent_id,
    color: "#000",
    type,
    is_active: 1,
    is_inputable: 1,
    sort_order: id,
    i18n_key: null,
  } as unknown as Category;
}

function mkEntry(
  category_id: number,
  year: number,
  month: number,
  amount: number,
): BudgetEntry {
  return {
    id: category_id * 100 + month,
    category_id,
    year,
    month,
    amount,
    notes: null,
  } as unknown as BudgetEntry;
}

describe.each<[Schema]>([["v2"], ["v1"]])(
  "regression: getBudgetVsActualData [%s]",
  (schema) => {
    it("aggregates leaf budgets under their parent and multiplies by -1 for expenses", async () => {
      const { categories, entries } = budgetFixture(schema);

      // Stub the 4 parallel selects in order:
      // 1) getAllActiveCategories
      // 2) getBudgetEntriesForYear
      // 3) getActualsByCategoryRange (month)
      // 4) getActualsByCategoryRange (ytd)
      mockSelect.mockImplementation((sql: string) => {
        if (/FROM categories/i.test(sql)) return Promise.resolve(categories);
        if (/FROM budget_entries WHERE year/i.test(sql))
          return Promise.resolve(entries);
        if (/GROUP BY category_id/i.test(sql)) return Promise.resolve([]);
        return Promise.resolve([]);
      });

      const rows = await getBudgetVsActualData(2026, 3);
      // We expect at least the two leaves to appear. In both schemas the
      // budget sum on the parent = -600 (expenses → sign -1).
      const leafIds = schema === "v2" ? [220, 221] : [1111, 1112];
      const parentId = schema === "v2" ? 22 : 1110;

      // Collect budgets per row keyed by category_id.
      const budgetById = new Map<number, number>();
      for (const r of rows) {
        budgetById.set(r.category_id, r.monthBudget);
      }

      // Both leaves get their stored budget × -1.
      expect(budgetById.get(leafIds[0])).toBe(-400);
      expect(budgetById.get(leafIds[1])).toBe(-200);
      // The parent aggregates to -600.
      expect(budgetById.get(parentId)).toBe(-600);
    });
  },
);

// ---------------------------------------------------------------------------
// dataExportService envelope round-trip parity — the SREF JSON format must
// remain identical before and after the migration.
// ---------------------------------------------------------------------------

function envelopeFixture(schema: Schema): ExportEnvelope {
  const catId = schema === "v2" ? 22 : 1111;
  return {
    export_type: "transactions_with_categories",
    app_version: "0.8.3-test",
    exported_at: "2026-04-20T00:00:00Z",
    data: {
      categories: [
        {
          id: catId,
          name: "Épicerie",
          parent_id: null,
          color: "#000",
          type: "expense",
          is_active: 1,
          is_inputable: 1,
          sort_order: 1,
          i18n_key: null,
        } as unknown as Category,
      ],
      suppliers: [],
      keywords: [],
      transactions: [],
    },
  };
}

describe.each<[Schema]>([["v2"], ["v1"]])(
  "regression: SREF envelope round-trip [%s]",
  (schema) => {
    it("serialize → parse returns an equivalent envelope", () => {
      const original = envelopeFixture(schema);
      const serialized = serializeToJson(
        original.export_type,
        original.data,
        original.app_version,
      );
      const { envelope } = parseImportedJson(serialized);
      expect(envelope.export_type).toBe(original.export_type);
      expect(envelope.data.categories).toHaveLength(1);
      expect(envelope.data.categories![0].id).toBe(
        schema === "v2" ? 22 : 1111,
      );
    });
  },
);

// ---------------------------------------------------------------------------
// Split transactions — the parent_transaction_id / is_split columns must be
// honoured identically after a migration. We exercise them through the
// export envelope, which is the canonical observable surface.
// ---------------------------------------------------------------------------

describe.each<[Schema]>([["v2"], ["v1"]])(
  "regression: split transactions survive export [%s]",
  (schema) => {
    it("preserves is_split and parent_transaction_id in the envelope", () => {
      const parentCat = schema === "v2" ? 28 : 1521;
      const leg1Cat = schema === "v2" ? 28 : 1521;
      const leg2Cat = schema === "v2" ? 22 : 1111;
      const envelope: ExportEnvelope = {
        export_type: "transactions_with_categories",
        app_version: "0.8.3-test",
        exported_at: "2026-04-20T00:00:00Z",
        data: {
          categories: [],
          suppliers: [],
          keywords: [],
          transactions: [
            {
              id: 100,
              date: "2026-03-10",
              description: "STM Opus + snack",
              amount: -50,
              category_id: parentCat,
              category_name: null,
              original_description: null,
              notes: null,
              is_manually_categorized: 0,
              is_split: 1,
              parent_transaction_id: null,
            },
            {
              id: 101,
              date: "2026-03-10",
              description: "STM leg",
              amount: -30,
              category_id: leg1Cat,
              category_name: null,
              original_description: null,
              notes: null,
              is_manually_categorized: 1,
              is_split: 0,
              parent_transaction_id: 100,
            },
            {
              id: 102,
              date: "2026-03-10",
              description: "Snack leg",
              amount: -20,
              category_id: leg2Cat,
              category_name: null,
              original_description: null,
              notes: null,
              is_manually_categorized: 1,
              is_split: 0,
              parent_transaction_id: 100,
            },
          ],
        },
      };
      const json = serializeToJson(envelope.export_type, envelope.data, envelope.app_version);
      const parsed = parseImportedJson(json).envelope;
      const txs = parsed.data.transactions!;
      expect(txs).toHaveLength(3);
      expect(txs[0].is_split).toBe(1);
      expect(txs[1].parent_transaction_id).toBe(100);
      expect(txs[2].parent_transaction_id).toBe(100);
      // Category ids are schema-specific but never null.
      expect(typeof txs[0].category_id).toBe("number");
      expect(typeof txs[1].category_id).toBe("number");
      expect(typeof txs[2].category_id).toBe("number");
    });
  },
);
