import { describe, it, expect, beforeEach } from "vitest";
import {
  computeMigrationPlan,
  normalizeForMatch,
  __resetMappingServiceCachesForTests,
  type ProfileData,
  type V2CategoryInput,
  type V2KeywordInput,
  type V2TransactionInput,
  type V2SupplierInput,
} from "./categoryMappingService";
import { resetTaxonomyCache } from "./categoryTaxonomyService";

beforeEach(() => {
  resetTaxonomyCache();
  __resetMappingServiceCachesForTests();
});

// ---------------------------------------------------------------------------
// Fixture helpers — we build just enough of ProfileData per test to stay
// readable; everything defaults to empty arrays.
// ---------------------------------------------------------------------------

function makeProfile(partial: Partial<ProfileData>): ProfileData {
  return {
    v2Categories: partial.v2Categories ?? [],
    keywords: partial.keywords ?? [],
    transactions: partial.transactions ?? [],
    suppliers: partial.suppliers,
  };
}

function cat(id: number, name: string, parent_id: number | null = null): V2CategoryInput {
  return { id, name, parent_id };
}

function kw(category_id: number, keyword: string): V2KeywordInput {
  return { category_id, keyword };
}

function tx(id: number, description: string, category_id: number | null, supplier_id?: number): V2TransactionInput {
  return { id, description, category_id, supplier_id: supplier_id ?? null };
}

function sup(id: number, name: string): V2SupplierInput {
  return { id, name };
}

// ---------------------------------------------------------------------------
// normalizeForMatch
// ---------------------------------------------------------------------------

describe("normalizeForMatch", () => {
  it("lowercases, strips accents, and collapses spaces", () => {
    expect(normalizeForMatch("  Épicerie  Régulière ")).toBe("epicerie reguliere");
  });
  it("handles already-normalized text", () => {
    expect(normalizeForMatch("stm")).toBe("stm");
  });
});

// ---------------------------------------------------------------------------
// Custom categories → preserved bucket
// ---------------------------------------------------------------------------

describe("computeMigrationPlan — preserved (custom)", () => {
  it("moves a non-seeded v2 category into the preserved bucket", () => {
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [cat(9001, "Ma catégorie perso", 2)],
      })
    );
    expect(plan.rows).toHaveLength(0);
    expect(plan.preserved).toHaveLength(1);
    expect(plan.preserved[0]).toMatchObject({
      v2CategoryId: 9001,
      v2CategoryName: "Ma catégorie perso",
      v1TargetId: null,
      confidence: "none",
      reason: "preserved",
    });
  });

  it("ignores structural v2 parents (1–6)", () => {
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [cat(1, "Revenus"), cat(2, "Dépenses récurrentes")],
      })
    );
    expect(plan.rows).toHaveLength(0);
    expect(plan.preserved).toHaveLength(0);
    expect(plan.unresolved).toHaveLength(0);
  });
});

// ---------------------------------------------------------------------------
// Pass 1 — keyword match
// ---------------------------------------------------------------------------

describe("computeMigrationPlan — Pass 1 (keyword)", () => {
  it("maps Transport en commun → 1521 (Autobus & métro) via STM keyword", () => {
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [cat(28, "Transport en commun", 2)],
        keywords: [kw(28, "STM")],
      })
    );
    expect(plan.rows).toHaveLength(1);
    expect(plan.rows[0]).toMatchObject({
      v2CategoryId: 28,
      v1TargetId: 1521,
      v1TargetName: "Autobus & métro",
      confidence: "high",
      reason: "keyword",
    });
  });

  it("maps Voiture → 1512 (Essence) via SHELL keyword", () => {
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [cat(40, "Voiture", 3)],
        keywords: [kw(40, "SHELL")],
      })
    );
    expect(plan.rows[0]).toMatchObject({
      v1TargetId: 1512,
      confidence: "high",
      reason: "keyword",
    });
  });

  it("picks the first matching KEYWORD_TO_V1 rule when multiple apply", () => {
    // SAAQ (1514) wins over any later rule because the list order of the
    // user's keywords drives it.
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [cat(40, "Voiture", 3)],
        keywords: [kw(40, "SAAQ"), kw(40, "SHELL")],
      })
    );
    expect(plan.rows[0].v1TargetId).toBe(1514);
  });
});

// ---------------------------------------------------------------------------
// Pass 2 — supplier propagation
// ---------------------------------------------------------------------------

describe("computeMigrationPlan — Pass 2 (supplier)", () => {
  it("propagates via a transaction description when no v2 keyword matches", () => {
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [cat(28, "Transport en commun", 2)],
        // No keyword rows for cat 28.
        transactions: [tx(1, "PAIEMENT STM CARTE OPUS", 28)],
      })
    );
    expect(plan.rows[0]).toMatchObject({
      v1TargetId: 1521,
      confidence: "medium",
      reason: "supplier",
    });
  });

  it("propagates via a supplier name when the description has no hit", () => {
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [cat(47, "Voyage", 3)],
        transactions: [tx(1, "CARTE 1234", 47, 42)],
        suppliers: [sup(42, "Hilton Montreal")],
      })
    );
    expect(plan.rows[0]).toMatchObject({
      v1TargetId: 1533,
      confidence: "medium",
      reason: "supplier",
    });
  });
});

// ---------------------------------------------------------------------------
// Pass 3 — default fallback
// ---------------------------------------------------------------------------

describe("computeMigrationPlan — Pass 3 (default)", () => {
  it("maps Loyer (20) → 1211 with high confidence (direct)", () => {
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [cat(20, "Loyer", 2)],
      })
    );
    expect(plan.rows[0]).toMatchObject({
      v1TargetId: 1211,
      v1TargetName: "Loyer",
      confidence: "high",
      reason: "default",
    });
  });

  it("maps Restaurant (24) → 1121 with medium confidence", () => {
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [cat(24, "Restaurant", 2)],
      })
    );
    expect(plan.rows[0]).toMatchObject({
      v1TargetId: 1121,
      confidence: "medium",
      reason: "default",
    });
  });

  it("exposes splits for Transport en commun (28) when no keyword/supplier resolves it", () => {
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [cat(28, "Transport en commun", 2)],
      })
    );
    expect(plan.rows[0].splits).toEqual([
      { v1TargetId: 1521, v1TargetName: "Autobus & métro" },
      { v1TargetId: 1522, v1TargetName: "Train de banlieue" },
    ]);
    expect(plan.rows[0].confidence).toBe("medium");
    expect(plan.rows[0].reason).toBe("default");
    // Primary target is the "reste → X par défaut" (1521 per rationale).
    expect(plan.rows[0].v1TargetId).toBe(1521);
  });

  it("exposes 4-way splits for Voiture (40)", () => {
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [cat(40, "Voiture", 3)],
      })
    );
    expect(plan.rows[0].splits?.map((s) => s.v1TargetId)).toEqual([1512, 1513, 1514, 1515]);
    expect(plan.rows[0].v1TargetId).toBe(1513); // entretien par défaut
    expect(plan.rows[0].confidence).toBe("low");
  });
});

// ---------------------------------------------------------------------------
// Pass 4 — review
// ---------------------------------------------------------------------------

describe("computeMigrationPlan — Pass 4 (review)", () => {
  it("flags Projets (73) for review (no direct v1 equivalent)", () => {
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [cat(73, "Projets", 6)],
      })
    );
    expect(plan.rows[0]).toMatchObject({
      v1TargetId: null,
      v1TargetName: null,
      confidence: "none",
      reason: "review",
    });
    expect(plan.unresolved).toHaveLength(1);
  });

  it("escapes Pass 4 for Projets when a CLAUDE.AI keyword is present", () => {
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [cat(73, "Projets", 6)],
        keywords: [kw(73, "CLAUDE.AI")],
      })
    );
    expect(plan.rows[0]).toMatchObject({
      v1TargetId: 1734, // Abonnements professionnels
      confidence: "high",
      reason: "keyword",
    });
  });
});

// ---------------------------------------------------------------------------
// Stats & aggregation
// ---------------------------------------------------------------------------

describe("computeMigrationPlan — stats", () => {
  it("reports per-confidence counts matching the rows", () => {
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [
          cat(20, "Loyer", 2),          // high (default direct)
          cat(24, "Restaurant", 2),      // medium (default)
          cat(40, "Voiture", 3),         // low (split, low confidence)
          cat(73, "Projets", 6),         // none
        ],
      })
    );
    expect(plan.stats).toEqual({
      total: 4,
      high: 1,
      medium: 1,
      low: 1,
      none: 1,
    });
    expect(plan.unresolved).toHaveLength(1);
    expect(plan.unresolved[0].v2CategoryId).toBe(73);
  });

  it("returns empty structures for an empty profile", () => {
    const plan = computeMigrationPlan(makeProfile({}));
    expect(plan.rows).toEqual([]);
    expect(plan.preserved).toEqual([]);
    expect(plan.unresolved).toEqual([]);
    expect(plan.stats).toEqual({ total: 0, high: 0, medium: 0, low: 0, none: 0 });
  });

  it("handles a mixed profile with seeded + custom categories in one call", () => {
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [
          cat(1, "Revenus"),                    // structural → skipped
          cat(22, "Épicerie", 2),               // high default
          cat(9002, "Dépenses projet X", 3),    // custom → preserved
        ],
      })
    );
    expect(plan.rows).toHaveLength(1);
    expect(plan.rows[0].v2CategoryId).toBe(22);
    expect(plan.preserved).toHaveLength(1);
    expect(plan.preserved[0].v2CategoryId).toBe(9002);
  });
});

// ---------------------------------------------------------------------------
// Pass priority — keyword beats default
// ---------------------------------------------------------------------------

describe("computeMigrationPlan — pass priority", () => {
  it("Pass 1 (keyword) wins over Pass 3 (default) on split categories", () => {
    // Transport en commun (28) default is 1521; with a GARE CENTRALE keyword
    // Pass 1 should push to 1522 (Train) instead.
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [cat(28, "Transport en commun", 2)],
        keywords: [kw(28, "GARE CENTRALE")],
      })
    );
    expect(plan.rows[0]).toMatchObject({
      v1TargetId: 1522,
      confidence: "high",
      reason: "keyword",
    });
    // Splits are NOT exposed when Pass 1 resolves the row — only Pass 3
    // attaches them.
    expect(plan.rows[0].splits).toBeUndefined();
  });

  it("Pass 2 (supplier) wins over Pass 3 (default)", () => {
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [cat(40, "Voiture", 3)],
        // No v2 keyword row on cat 40 — description drives it.
        transactions: [tx(1, "PETRO-CANADA #1234 MTL", 40)],
      })
    );
    expect(plan.rows[0]).toMatchObject({
      v1TargetId: 1512, // Essence
      confidence: "medium",
      reason: "supplier",
    });
  });
});

// ---------------------------------------------------------------------------
// DEFAULT_MAPPINGS exhaustive coverage — one expectation per seeded v2 id
// ---------------------------------------------------------------------------

describe("computeMigrationPlan — DEFAULT_MAPPINGS exhaustive", () => {
  // Each entry: [v2Id, v2Name, expectedV1TargetId|null, expectedBadge, isSplit]
  const CASES: Array<[number, string, number | null, "high" | "medium" | "low" | "none", boolean]> = [
    // Revenus
    [10, "Paie", 1011, "high", false],
    [11, "Autres revenus", 1090, "high", false],
    // Dépenses récurrentes
    [20, "Loyer", 1211, "high", false],
    [21, "Électricité", 1221, "high", false],
    [22, "Épicerie", 1111, "high", false],
    [23, "Dons", 1931, "high", false],
    [24, "Restaurant", 1121, "medium", false],
    [25, "Frais bancaires", 1911, "high", false],
    [26, "Jeux Films & Livres", 1710, "low", true],
    [27, "Abonnements Musique", 1714, "high", false],
    [28, "Transport en commun", 1521, "medium", true],
    [29, "Internet & Télécom", 1231, "medium", true],
    [30, "Animaux", 1751, "medium", false],
    [31, "Assurances", 1250, "low", true],
    [32, "Pharmacie", 1611, "high", false],
    [33, "Taxes municipales", 1213, "high", false],
    // Dépenses ponctuelles
    [40, "Voiture", 1513, "low", true],
    [41, "Amazon", 1946, "medium", false],
    [42, "Électroniques", 1312, "low", false],
    [43, "Alcool", 1810, "high", false],
    [44, "Cadeaux", 1940, "high", false],
    [45, "Vêtements", 1410, "medium", false],
    [46, "CPA", 1932, "high", false],
    [47, "Voyage", 1533, "medium", true],
    [48, "Sports & Plein air", 1722, "medium", true],
    [49, "Spectacles & sorties", 1711, "high", false],
    // Maison
    [50, "Hypothèque", 1212, "high", false],
    [51, "Achats maison", 1243, "medium", false],
    [52, "Entretien maison", 1241, "high", false],
    [53, "Électroménagers & Meubles", 1311, "medium", true],
    [54, "Outils", 1243, "high", false],
    // Placements
    [60, "Placements", 1964, "medium", false],
    [61, "Transferts internes", 1980, "high", false],
    // Autres
    [70, "Impôts", 1922, "medium", false],
    [71, "Paiement CC", 1971, "high", false],
    [72, "Retrait cash", 1945, "high", false],
    [73, "Projets", null, "none", false],
    // Assurances children (already split in some profiles)
    [310, "Assurance-auto", 1516, "high", false],
    [311, "Assurance-habitation", 1250, "high", false],
    [312, "Assurance-vie", 1630, "high", false],
  ];

  it.each(CASES)(
    "v2 id %i (%s) → v1 target %s with %s badge",
    (v2Id, v2Name, expectedV1, expectedBadge, isSplit) => {
      const plan = computeMigrationPlan(
        makeProfile({ v2Categories: [cat(v2Id, v2Name, 2)] })
      );
      expect(plan.rows).toHaveLength(1);
      const row = plan.rows[0];
      expect(row.v2CategoryId).toBe(v2Id);
      expect(row.v1TargetId).toBe(expectedV1);
      expect(row.confidence).toBe(expectedBadge);
      if (isSplit) {
        expect(row.splits).toBeDefined();
        expect(row.splits!.length).toBeGreaterThan(1);
      }
    }
  );
});

// ---------------------------------------------------------------------------
// Keyword → V1 rules exhaustive coverage
// ---------------------------------------------------------------------------

describe("computeMigrationPlan — KEYWORD_TO_V1 rules", () => {
  // Each entry: [keyword, expected v1 id] — one test per rule so regressions
  // on the mapping table fail individually with a clear signal.
  const KW_RULES: Array<[string, number]> = [
    // Jeux/Films/Livres
    ["STEAMGAMES", 1712],
    ["PLAYSTATION", 1712],
    ["NINTENDO", 1712],
    ["PRIMEVIDEO", 1713],
    ["RENAUD-BRAY", 1741],
    ["CINEMA DU PARC", 1711],
    ["LEGO", 1715],
    // Transport
    ["STM", 1521],
    ["GARE MONT-SAINT", 1522],
    ["GARE SAINT-HUBERT", 1522],
    ["GARE CENTRALE", 1522],
    ["REM", 1522],
    // Voiture
    ["SHELL", 1512],
    ["ESSO", 1512],
    ["ULTRAMAR", 1512],
    ["PETRO-CANADA", 1512],
    ["CREVIER", 1512],
    ["SAAQ", 1514],
    // Assurances
    ["BELAIR", 1250],
    ["PRYSM", 1250],
    ["INS/ASS", 1630],
    // Voyage
    ["NORWEGIAN CRUISE", 1533],
    ["AEROPORTS DE MONTREAL", 1531],
    ["HILTON", 1533],
    // Sports
    ["SEPAQ", 1723],
    ["BLOC SHOP", 1723],
    ["MOUNTAIN EQUIPMENT", 1722],
    ["DECATHLON", 1722],
    ["LA CORDEE", 1722],
    ["PHYSIOACTIF", 1615],
    // Meubles
    ["TANGUAY", 1311],
    ["BOUCLAIR", 1311],
    // Projets
    ["CLAUDE.AI", 1734],
    ["NAME-CHEAP", 1734],
  ];

  it.each(KW_RULES)(
    "keyword %s resolves to v1 leaf %i with high confidence",
    (keyword, expectedV1) => {
      // Attach the keyword to any seeded v2 cat id — the keyword rules win
      // regardless of the host v2 cat (that's by design of Pass 1).
      const plan = computeMigrationPlan(
        makeProfile({
          v2Categories: [cat(73, "Projets", 6)],
          keywords: [kw(73, keyword)],
        })
      );
      expect(plan.rows[0].v1TargetId).toBe(expectedV1);
      expect(plan.rows[0].confidence).toBe("high");
      expect(plan.rows[0].reason).toBe("keyword");
    }
  );

  it("propagates keyword match even when the raw v2 keyword has mixed case and spaces", () => {
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [cat(28, "Transport en commun", 2)],
        keywords: [kw(28, "  Gare Centrale  ")],
      })
    );
    expect(plan.rows[0].v1TargetId).toBe(1522);
  });

  it("matches a keyword that literally names a v1 leaf (Loyer → 1211)", () => {
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [cat(20, "Loyer", 2)],
        keywords: [kw(20, "loyer")],
      })
    );
    // With a matching leaf-name keyword, Pass 1 resolves with high confidence
    // (instead of falling through to Pass 3's high-confidence default; both
    // resolve to 1211, but the reason must be "keyword").
    expect(plan.rows[0].v1TargetId).toBe(1211);
    expect(plan.rows[0].reason).toBe("keyword");
  });
});

// ---------------------------------------------------------------------------
// Pass 2 — deeper coverage
// ---------------------------------------------------------------------------

describe("computeMigrationPlan — Pass 2 deeper cases", () => {
  it("falls through to Pass 3 when neither keyword nor supplier matches anything", () => {
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [cat(40, "Voiture", 3)],
        transactions: [tx(1, "SOMETHING UNRELATED", 40)],
      })
    );
    // Default split applies: primary = 1513 (entretien).
    expect(plan.rows[0]).toMatchObject({
      v1TargetId: 1513,
      confidence: "low",
      reason: "default",
    });
    expect(plan.rows[0].splits?.map((s) => s.v1TargetId)).toEqual([1512, 1513, 1514, 1515]);
  });

  it("ignores suppliers that are in the map but not attached to any tx", () => {
    // Pass 2 iterates over transactions; a loose supplier list is irrelevant.
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [cat(40, "Voiture", 3)],
        transactions: [tx(1, "MISC", 40)],
        suppliers: [{ id: 777, name: "Shell" }],
      })
    );
    // Without a tx.supplier_id pointing at 777, the Shell rule never fires.
    expect(plan.rows[0].reason).toBe("default");
  });
});

// ---------------------------------------------------------------------------
// Preserved — multiple custom categories
// ---------------------------------------------------------------------------

describe("computeMigrationPlan — preserved (multiple)", () => {
  it("collects all 3 custom categories under preserved[] with reason='preserved'", () => {
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [
          cat(9001, "Projet maison", 3),
          cat(9002, "Activités enfants", 3),
          cat(9003, "Hobby moto", 3),
        ],
      })
    );
    expect(plan.preserved).toHaveLength(3);
    for (const row of plan.preserved) {
      expect(row.reason).toBe("preserved");
      expect(row.v1TargetId).toBeNull();
      expect(row.confidence).toBe("none");
    }
  });

  it("keeps seeded + custom in different buckets when both are present", () => {
    const plan = computeMigrationPlan(
      makeProfile({
        v2Categories: [
          cat(22, "Épicerie", 2),
          cat(9999, "Projet perso", 3),
        ],
      })
    );
    expect(plan.rows.map((r) => r.v2CategoryId)).toEqual([22]);
    expect(plan.preserved.map((r) => r.v2CategoryId)).toEqual([9999]);
  });
});
