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

// `getDb` is stubbed with a fake DB that captures every SQL statement issued
// during applyMigration. This is a unit test — we do NOT run a real SQLite.

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())),
  };
});

// `setPreference` goes through the same fake DB — no extra stubbing needed
// because userPreferenceService itself calls getDb().

import { getDb } from "./db";
import { applyMigration, markSchemaVersionV1 } from "./categoryMigrationService";
import type { MigrationPlan, MappingRow } from "./categoryMappingService";
import type { BackupResult } from "./categoryBackupService";

interface CapturedCall {
  sql: string;
  params?: unknown[];
}

interface FakeDb {
  calls: CapturedCall[];
  failAt: { sql: RegExp; error: string } | null;
  select: ReturnType<typeof vi.fn>;
  execute: ReturnType<typeof vi.fn>;
}

function makeFakeDb(): FakeDb {
  const db: FakeDb = {
    calls: [],
    failAt: null,
    select: vi.fn(async () => []),
    execute: vi.fn(),
  };
  db.execute.mockImplementation(async (sql: string, params?: unknown[]) => {
    db.calls.push({ sql, params });
    if (db.failAt && db.failAt.sql.test(sql)) {
      throw new Error(db.failAt.error);
    }
    // Return a rowsAffected=1 for UPDATE/DELETE/INSERT, 0 for BEGIN/COMMIT/ROLLBACK.
    const upper = sql.trim().toUpperCase();
    if (/^(BEGIN|COMMIT|ROLLBACK)/.test(upper)) return { rowsAffected: 0 };
    // Heuristic: only simulate a write on statements that look like real
    // UPDATE/DELETE/INSERT OR IGNORE — this gives us stable counts in tests.
    return { rowsAffected: 1 };
  });
  return db;
}

const FAKE_BACKUP: BackupResult = {
  path: "/tmp/profile-backup.sref",
  size: 4096,
  checksum: "abc123",
  verifiedAt: "2026-04-20T12:00:00Z",
  encrypted: false,
};

function makeRow(v2: number, v1: number | null): MappingRow {
  return {
    v2CategoryId: v2,
    v2CategoryName: `v2-${v2}`,
    v1TargetId: v1,
    v1TargetName: v1 === null ? null : `v1-${v1}`,
    confidence: v1 === null ? "none" : "high",
    reason: v1 === null ? "review" : "keyword",
  };
}

function makePlan(
  rows: MappingRow[],
  preserved: MappingRow[] = [],
): MigrationPlan {
  const unresolved = rows.filter((r) => r.v1TargetId === null);
  return {
    rows,
    preserved,
    unresolved,
    stats: {
      total: rows.length,
      high: rows.filter((r) => r.confidence === "high").length,
      medium: rows.filter((r) => r.confidence === "medium").length,
      low: rows.filter((r) => r.confidence === "low").length,
      none: unresolved.length,
    },
  };
}

let fake: FakeDb;

beforeEach(() => {
  fake = makeFakeDb();
  vi.mocked(getDb).mockResolvedValue(fake as never);
});

// ---------------------------------------------------------------------------
// Guard: invalid backup short-circuits before any SQL runs
// ---------------------------------------------------------------------------

describe("applyMigration — backup guard", () => {
  it("returns succeeded=false with no DB calls when backup has no path", async () => {
    const outcome = await applyMigration(
      makePlan([makeRow(10, 1011)]),
      { ...FAKE_BACKUP, path: "" },
    );
    expect(outcome.succeeded).toBe(false);
    expect(outcome.error).toMatch(/invalid_backup/);
    expect(fake.calls).toHaveLength(0);
  });

  it("returns succeeded=false when backup has no checksum", async () => {
    const outcome = await applyMigration(
      makePlan([makeRow(10, 1011)]),
      { ...FAKE_BACKUP, checksum: "" },
    );
    expect(outcome.succeeded).toBe(false);
    expect(outcome.error).toMatch(/invalid_backup/);
    expect(fake.calls).toHaveLength(0);
  });
});

// ---------------------------------------------------------------------------
// Happy path sequencing
// ---------------------------------------------------------------------------

describe("applyMigration — happy path sequencing", () => {
  it("wraps the entire run in BEGIN / COMMIT", async () => {
    const outcome = await applyMigration(
      makePlan([makeRow(22, 1111)]),
      FAKE_BACKUP,
    );
    expect(outcome.succeeded).toBe(true);
    const trimmed = fake.calls.map((c) => c.sql.trim().toUpperCase());
    expect(trimmed[0]).toBe("BEGIN");
    expect(trimmed[trimmed.length - 1]).toBe("COMMIT");
  });

  it("emits an UPDATE on transactions for each mapped v2 id", async () => {
    await applyMigration(
      makePlan([makeRow(22, 1111), makeRow(24, 1121)]),
      FAKE_BACKUP,
    );
    const txUpdates = fake.calls.filter((c) =>
      /UPDATE transactions SET category_id/i.test(c.sql),
    );
    expect(txUpdates.length).toBe(2);
    expect(txUpdates[0].params).toEqual([1111, 22]);
    expect(txUpdates[1].params).toEqual([1121, 24]);
  });

  it("rewrites budget_entries and budget_template_entries", async () => {
    await applyMigration(
      makePlan([makeRow(22, 1111)]),
      FAKE_BACKUP,
    );
    const budgetEntries = fake.calls.filter((c) =>
      /UPDATE budget_entries SET category_id/i.test(c.sql),
    );
    const templateEntries = fake.calls.filter((c) =>
      /UPDATE budget_template_entries SET category_id/i.test(c.sql),
    );
    expect(budgetEntries.length).toBe(1);
    expect(templateEntries.length).toBe(1);
  });

  it("rewrites keywords and suppliers", async () => {
    await applyMigration(
      makePlan([makeRow(22, 1111)]),
      FAKE_BACKUP,
    );
    const kwUpdates = fake.calls.filter((c) =>
      /UPDATE keywords SET category_id/i.test(c.sql),
    );
    const supUpdates = fake.calls.filter((c) =>
      /UPDATE suppliers SET category_id/i.test(c.sql),
    );
    expect(kwUpdates.length).toBe(1);
    expect(supUpdates.length).toBe(1);
  });

  it("inserts all v1 taxonomy rows (INSERT OR IGNORE)", async () => {
    await applyMigration(
      makePlan([makeRow(22, 1111)]),
      FAKE_BACKUP,
    );
    const v1Inserts = fake.calls.filter((c) =>
      /INSERT OR IGNORE INTO categories/i.test(c.sql),
    );
    // The v1 taxonomy has >100 rows; a low bound is safe as a regression
    // signal without coupling to the exact count.
    expect(v1Inserts.length).toBeGreaterThan(50);
  });

  it("soft-deletes v2 seeded categories (is_active=0) and the 1..6 structural parents", async () => {
    await applyMigration(
      makePlan([makeRow(22, 1111)]),
      FAKE_BACKUP,
    );
    const deactivateSeeded = fake.calls.filter(
      (c) =>
        /UPDATE categories SET is_active = 0 WHERE id = \$1/i.test(c.sql) &&
        (c.params?.[0] as number) === 22,
    );
    const deactivateParents = fake.calls.filter((c) =>
      /WHERE id IN \(1, 2, 3, 4, 5, 6\)/i.test(c.sql),
    );
    expect(deactivateSeeded.length).toBe(1);
    expect(deactivateParents.length).toBe(1);
  });

  it("writes categories_schema_version=v1 and a journal entry to user_preferences", async () => {
    await applyMigration(
      makePlan([makeRow(22, 1111)]),
      FAKE_BACKUP,
    );
    const prefWrites = fake.calls.filter((c) =>
      /INSERT INTO user_preferences/i.test(c.sql),
    );
    expect(prefWrites.length).toBe(2);
    expect(prefWrites[0].params?.[0]).toBe("categories_schema_version");
    expect(prefWrites[1].params?.[0]).toBe("last_categories_migration");
    // Journal is serialized JSON; sniff a stable key to avoid snapshot churn.
    const journalJson = prefWrites[1].params?.[1] as string;
    expect(journalJson).toContain('"timestamp"');
    expect(journalJson).toContain('"backupPath"');
    expect(journalJson).toContain(FAKE_BACKUP.path);
  });

  it("ignores rows whose v1TargetId is null (never emits UPDATE for them)", async () => {
    await applyMigration(
      makePlan([makeRow(22, 1111), makeRow(73, null)]),
      FAKE_BACKUP,
    );
    const txUpdates = fake.calls.filter((c) =>
      /UPDATE transactions SET category_id/i.test(c.sql),
    );
    // Only one mapping (22 → 1111) was resolved; the unresolved row is
    // skipped inside buildMappingFromRows.
    expect(txUpdates.length).toBe(1);
  });
});

// ---------------------------------------------------------------------------
// Custom categories (preserved)
// ---------------------------------------------------------------------------

describe("applyMigration — preserved custom categories", () => {
  it("creates the 'Catégories personnalisées (migration)' parent when preserved is non-empty", async () => {
    const outcome = await applyMigration(
      makePlan([makeRow(22, 1111)], [makeRow(9001, null)]),
      FAKE_BACKUP,
    );
    expect(outcome.succeeded).toBe(true);
    const parentInserts = fake.calls.filter(
      (c) =>
        /INSERT OR IGNORE INTO categories/i.test(c.sql) &&
        (c.params?.[0] as number) === 2000,
    );
    expect(parentInserts.length).toBe(1);
    expect(parentInserts[0].params?.[4]).toBe(
      "categoriesSeed.migration.customParent",
    );
  });

  it("does NOT create the custom parent when preserved is empty", async () => {
    await applyMigration(
      makePlan([makeRow(22, 1111)]),
      FAKE_BACKUP,
    );
    const parentInserts = fake.calls.filter(
      (c) =>
        /INSERT OR IGNORE INTO categories/i.test(c.sql) &&
        (c.params?.[0] as number) === 2000,
    );
    expect(parentInserts.length).toBe(0);
  });

  it("re-parents each preserved v2 category under the new parent id (2000)", async () => {
    await applyMigration(
      makePlan(
        [makeRow(22, 1111)],
        [makeRow(9001, null), makeRow(9002, null), makeRow(9003, null)],
      ),
      FAKE_BACKUP,
    );
    const reparent = fake.calls.filter((c) =>
      /UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql),
    );
    expect(reparent.length).toBe(3);
    for (const call of reparent) {
      expect(call.params?.[0]).toBe(2000);
    }
    expect(reparent.map((c) => c.params?.[1])).toEqual([9001, 9002, 9003]);
  });
});

// ---------------------------------------------------------------------------
// Merged custom categories (#259)
// ---------------------------------------------------------------------------

describe("applyMigration — merged custom categories (#259)", () => {
  it("reassigns transactions/budgets/keywords/suppliers of a merged custom to the chosen leaf", async () => {
    // Custom category 9001 merged into leaf 1444.
    await applyMigration(
      makePlan([makeRow(22, 1111)], [makeRow(9001, 1444)]),
      FAKE_BACKUP,
    );
    const merged = (re: RegExp) =>
      fake.calls.filter(
        (c) => re.test(c.sql) && (c.params?.[1] as number) === 9001,
      );
    // Each reassignment must target the chosen leaf (1444), keyed by the custom
    // id (9001) — not merely be emitted.
    expect(merged(/UPDATE transactions SET category_id/i)[0]?.params).toEqual([
      1444, 9001,
    ]);
    expect(merged(/UPDATE budget_entries SET category_id/i)[0]?.params).toEqual([
      1444, 9001,
    ]);
    expect(merged(/UPDATE keywords SET category_id/i)[0]?.params).toEqual([
      1444, 9001,
    ]);
    expect(merged(/UPDATE suppliers SET category_id/i)[0]?.params).toEqual([
      1444, 9001,
    ]);
  });

  it("soft-deletes a merged custom (is_active=0) and does NOT re-parent it", async () => {
    await applyMigration(
      makePlan([makeRow(22, 1111)], [makeRow(9001, 1444)]),
      FAKE_BACKUP,
    );
    const deactivate = fake.calls.filter(
      (c) =>
        /UPDATE categories SET is_active = 0 WHERE id = \$1/i.test(c.sql) &&
        (c.params?.[0] as number) === 9001,
    );
    const reparent = fake.calls.filter(
      (c) =>
        /UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql) &&
        (c.params?.[1] as number) === 9001,
    );
    expect(deactivate.length).toBe(1);
    expect(reparent.length).toBe(0);
  });

  it("does NOT create the custom parent when every preserved custom is merged", async () => {
    await applyMigration(
      makePlan([makeRow(22, 1111)], [makeRow(9001, 1444), makeRow(9002, 1555)]),
      FAKE_BACKUP,
    );
    const parentInserts = fake.calls.filter(
      (c) =>
        /INSERT OR IGNORE INTO categories/i.test(c.sql) &&
        (c.params?.[0] as number) === 2000,
    );
    expect(parentInserts.length).toBe(0);
  });

  it("creates the custom parent when at least one preserved custom is left unmerged", async () => {
    await applyMigration(
      makePlan([makeRow(22, 1111)], [makeRow(9001, 1444), makeRow(9002, null)]),
      FAKE_BACKUP,
    );
    const parentInserts = fake.calls.filter(
      (c) =>
        /INSERT OR IGNORE INTO categories/i.test(c.sql) &&
        (c.params?.[0] as number) === 2000,
    );
    expect(parentInserts.length).toBe(1);
    // Only the unmerged custom (9002) is re-parented; the merged one (9001) is not.
    const reparent = fake.calls.filter((c) =>
      /UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql),
    );
    expect(reparent.map((c) => c.params?.[1])).toEqual([9002]);
  });

  it("leaves no orphan when a custom PARENT is merged but its custom CHILD is not (regression)", async () => {
    // plan.preserved is a flat list: a merged parent (9001 → 1444) and its
    // unresolved child (9002). The child must land under the bucket (2000) and
    // the parent be deactivated — the child never dangles under a dead parent.
    await applyMigration(
      makePlan([makeRow(22, 1111)], [makeRow(9001, 1444), makeRow(9002, null)]),
      FAKE_BACKUP,
    );
    // Child re-parented under 2000.
    const childReparent = fake.calls.filter(
      (c) =>
        /UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql) &&
        (c.params?.[1] as number) === 9002,
    );
    expect(childReparent.length).toBe(1);
    expect(childReparent[0].params?.[0]).toBe(2000);
    // Parent merged → deactivated, not re-parented.
    const parentDeactivate = fake.calls.filter(
      (c) =>
        /UPDATE categories SET is_active = 0 WHERE id = \$1/i.test(c.sql) &&
        (c.params?.[0] as number) === 9001,
    );
    const parentReparent = fake.calls.filter(
      (c) =>
        /UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql) &&
        (c.params?.[1] as number) === 9001,
    );
    expect(parentDeactivate.length).toBe(1);
    expect(parentReparent.length).toBe(0);
  });
});

// ---------------------------------------------------------------------------
// Rollback on SQL failure
// ---------------------------------------------------------------------------

describe("applyMigration — rollback on SQL failure", () => {
  it("emits ROLLBACK when an UPDATE throws mid-run", async () => {
    fake.failAt = {
      sql: /UPDATE transactions SET category_id/i,
      error: "fk_violation",
    };
    const outcome = await applyMigration(
      makePlan([makeRow(22, 1111)]),
      FAKE_BACKUP,
    );
    expect(outcome.succeeded).toBe(false);
    expect(outcome.error).toMatch(/fk_violation/);
    const upper = fake.calls.map((c) => c.sql.trim().toUpperCase());
    expect(upper).toContain("ROLLBACK");
    expect(upper).not.toContain("COMMIT");
  });

  it("does not clobber the outcome.error even when ROLLBACK itself fails", async () => {
    // First throw on transactions UPDATE; the subsequent ROLLBACK must not
    // overwrite the original error.
    let seen = 0;
    fake.execute.mockImplementation(async (sql: string) => {
      fake.calls.push({ sql });
      seen++;
      if (/UPDATE transactions SET category_id/i.test(sql)) {
        throw new Error("original_sql_error");
      }
      if (/^ROLLBACK/i.test(sql.trim())) {
        throw new Error("rollback_also_failed");
      }
      return { rowsAffected: 1 };
    });
    const outcome = await applyMigration(
      makePlan([makeRow(22, 1111)]),
      FAKE_BACKUP,
    );
    expect(outcome.succeeded).toBe(false);
    expect(outcome.error).toMatch(/original_sql_error/);
    expect(seen).toBeGreaterThan(2);
  });
});

// ---------------------------------------------------------------------------
// markSchemaVersionV1 — helper
// ---------------------------------------------------------------------------

describe("markSchemaVersionV1", () => {
  it("writes an upsert into user_preferences with key=categories_schema_version", async () => {
    await markSchemaVersionV1();
    const prefWrite = fake.calls.find((c) =>
      /INSERT INTO user_preferences/i.test(c.sql),
    );
    expect(prefWrite).toBeDefined();
    expect(prefWrite!.params?.[0]).toBe("categories_schema_version");
    expect(prefWrite!.params?.[1]).toBe("v1");
  });
});
