/**
 * Integration-flavoured tests for the v2 → v1 categories migration.
 *
 * We intentionally do NOT spin up a real sqlite: the `tauri-plugin-sql` bridge
 * is only available inside the Tauri WebView. Instead we use a carefully
 * crafted in-memory fake DB that:
 *   - records every SQL statement (so we can assert ordering),
 *   - simulates `rowsAffected` for UPDATEs,
 *   - optionally fails on a matched SQL to simulate a mid-run error.
 *
 * The goal is to catch cross-service ordering bugs and the plan→backup→migrate
 * sequencing required by the spec (ADR: pre-migration backup is a hard gate).
 */

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

vi.mock("@tauri-apps/api/core", () => ({
  invoke: vi.fn(),
}));

vi.mock("@tauri-apps/api/app", () => ({
  getVersion: vi.fn(async () => "0.8.3-test"),
}));

// dataExportService helpers are used by both the backup service and the
// restore service. We mock the "DB-reading" ones and keep the parsers real.
vi.mock("../services/dataExportService", async () => {
  const actual = await vi.importActual<
    typeof import("../services/dataExportService")
  >("../services/dataExportService");
  return {
    ...actual,
    getExportCategories: vi.fn(async () => []),
    getExportSuppliers: vi.fn(async () => []),
    getExportKeywords: vi.fn(async () => []),
    getExportTransactions: vi.fn(async () => []),
    importTransactionsWithCategories: vi.fn(async () => undefined),
  };
});

import { getDb } from "../services/db";
import { invoke } from "@tauri-apps/api/core";
import { computeMigrationPlan } from "../services/categoryMappingService";
import { createPreMigrationBackup } from "../services/categoryBackupService";
import { applyMigration } from "../services/categoryMigrationService";
import { restoreFromBackup } from "../services/categoryRestoreService";
import { importTransactionsWithCategories } from "../services/dataExportService";
import { makeV2Profile, makeV2ProfileWithCustom } from "../__fixtures__/profiles";
import type { Profile } from "../services/profileService";

const mockInvoke = vi.mocked(invoke);

interface FakeDb {
  calls: Array<{ sql: string; params?: unknown[] }>;
  failAt: { sql: RegExp; error: string } | null;
  preferences: Map<string, string>;
  select: ReturnType<typeof vi.fn>;
  execute: ReturnType<typeof vi.fn>;
}

function makeFakeDb(): FakeDb {
  const db: FakeDb = {
    calls: [],
    failAt: null,
    preferences: new Map(),
    select: vi.fn(),
    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);
    }
    // Capture preference upserts so readLastMigrationJournal / getPreference
    // can observe the written values.
    if (/INSERT INTO user_preferences/i.test(sql) && params) {
      const [key, value] = params as [string, string];
      db.preferences.set(key, value);
    }
    const upper = sql.trim().toUpperCase();
    if (/^(BEGIN|COMMIT|ROLLBACK)/.test(upper)) return { rowsAffected: 0 };
    return { rowsAffected: 1 };
  });
  db.select.mockImplementation(async (sql: string, params?: unknown[]) => {
    if (/FROM user_preferences WHERE key/i.test(sql)) {
      const [key] = (params as [string]) ?? [""];
      const value = db.preferences.get(key);
      return value ? [{ key, value, updated_at: "2026-04-20T00:00:00Z" }] : [];
    }
    return [];
  });
  return db;
}

let fake: FakeDb;

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

const PROFILE: Profile = {
  id: "p1",
  name: "Max",
  color: "#f59e0b",
  pin_hash: null,
  db_filename: "max.db",
  created_at: "2026-01-01T00:00:00Z",
};

// ---------------------------------------------------------------------------
// Flow 1 — plan → backup → migrate on a realistic v2 profile
// ---------------------------------------------------------------------------

describe("integration: plan → backup → migrate (happy path)", () => {
  it("produces a plan, verifies backup round-trip, then applies migration atomically", async () => {
    // 1. Compute the plan from a realistic v2 profile fixture.
    const plan = computeMigrationPlan(makeV2Profile());
    expect(plan.rows.length).toBeGreaterThan(20);

    // 2. Backup invoke stubs — round-trip the content so the checksum matches.
    let captured: string | null = null;
    mockInvoke.mockImplementation(async (cmd, args) => {
      if (cmd === "ensure_backup_dir") return "/tmp/sr-backups";
      if (cmd === "write_export_file") {
        captured = (args as { content: string }).content;
        return undefined;
      }
      if (cmd === "read_import_file") return captured ?? "";
      if (cmd === "get_file_size") return 2048;
      throw new Error(`unexpected invoke: ${cmd}`);
    });

    const backup = await createPreMigrationBackup({ profile: PROFILE });
    expect(backup.encrypted).toBe(false);
    expect(backup.path).toMatch(/\.sref$/);

    // 3. Apply the migration.
    const outcome = await applyMigration(plan, backup);
    expect(outcome.succeeded).toBe(true);
    expect(outcome.backupPath).toBe(backup.path);
    // Plan has no unresolved after default fallback; `customPreservedCount`
    // is 0 because the fixture has no custom cats.
    expect(outcome.customPreservedCount).toBe(0);

    // 4. Verify ordering: BEGIN comes before any UPDATE and COMMIT is last.
    const upperCalls = fake.calls.map((c) => c.sql.trim().toUpperCase());
    const beginIdx = upperCalls.indexOf("BEGIN");
    const commitIdx = upperCalls.indexOf("COMMIT");
    expect(beginIdx).toBeGreaterThanOrEqual(0);
    expect(commitIdx).toBeGreaterThan(beginIdx);
    expect(commitIdx).toBe(upperCalls.length - 1);
  });
});

// ---------------------------------------------------------------------------
// Flow 2 — custom-preserving migration on a profile with custom cats
// ---------------------------------------------------------------------------

describe("integration: migration preserves custom categories", () => {
  it("creates the custom parent and re-parents the 3 custom cats", async () => {
    const plan = computeMigrationPlan(makeV2ProfileWithCustom());
    expect(plan.preserved).toHaveLength(3);

    // Backup stub (plaintext)
    let captured: string | null = null;
    mockInvoke.mockImplementation(async (cmd, args) => {
      if (cmd === "ensure_backup_dir") return "/tmp/sr-backups";
      if (cmd === "write_export_file") {
        captured = (args as { content: string }).content;
        return undefined;
      }
      if (cmd === "read_import_file") return captured ?? "";
      if (cmd === "get_file_size") return 2048;
      throw new Error(`unexpected invoke: ${cmd}`);
    });

    const backup = await createPreMigrationBackup({ profile: PROFILE });
    const outcome = await applyMigration(plan, backup);
    expect(outcome.succeeded).toBe(true);
    expect(outcome.customPreservedCount).toBe(3);

    // Custom parent created (id 2000)
    const parentInsert = fake.calls.find(
      (c) =>
        /INSERT OR IGNORE INTO categories/i.test(c.sql) &&
        (c.params?.[0] as number) === 2000,
    );
    expect(parentInsert).toBeDefined();

    // All 3 customs re-parented
    const reparent = fake.calls.filter((c) =>
      /UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql),
    );
    expect(reparent.length).toBe(3);
  });
});

// ---------------------------------------------------------------------------
// Flow 3 — backup failure aborts (no DB writes)
// ---------------------------------------------------------------------------

describe("integration: backup failure aborts before any DB write", () => {
  it("migration never runs when createPreMigrationBackup throws", async () => {
    mockInvoke.mockImplementation(async (cmd) => {
      if (cmd === "ensure_backup_dir") return "/tmp/sr-backups";
      if (cmd === "write_export_file") throw new Error("disk full");
      throw new Error(`unexpected invoke: ${cmd}`);
    });

    const plan = computeMigrationPlan(makeV2Profile());

    // The caller (UI) is responsible for calling backup FIRST; this test
    // simulates that policy.
    let backupErr: Error | null = null;
    try {
      await createPreMigrationBackup({ profile: PROFILE });
    } catch (e) {
      backupErr = e as Error;
    }

    expect(backupErr).not.toBeNull();
    // No migration call yet → DB calls must be empty.
    expect(fake.calls).toHaveLength(0);

    // Guard: applyMigration with a fake empty/invalid backup still refuses.
    const outcome = await applyMigration(plan, {
      path: "",
      size: 0,
      checksum: "",
      verifiedAt: "",
      encrypted: false,
    });
    expect(outcome.succeeded).toBe(false);
    expect(fake.calls).toHaveLength(0);
  });
});

// ---------------------------------------------------------------------------
// Flow 4 — SQL failure rolls back and backup remains usable
// ---------------------------------------------------------------------------

describe("integration: SQL failure rolls back, backup remains", () => {
  it("ROLLBACK is emitted and the backup is unaffected", async () => {
    let writtenBackup: string | null = null;
    mockInvoke.mockImplementation(async (cmd, args) => {
      if (cmd === "ensure_backup_dir") return "/tmp/sr-backups";
      if (cmd === "write_export_file") {
        writtenBackup = (args as { content: string }).content;
        return undefined;
      }
      if (cmd === "read_import_file") return writtenBackup ?? "";
      if (cmd === "get_file_size") return 2048;
      // The migration never touches Tauri fs commands other than via
      // dataExportService helpers (already mocked). An unexpected cmd here
      // would signal a leak.
      throw new Error(`unexpected invoke: ${cmd}`);
    });

    const plan = computeMigrationPlan(makeV2Profile());
    const backup = await createPreMigrationBackup({ profile: PROFILE });
    expect(writtenBackup).not.toBeNull();

    // Force a mid-run SQL failure.
    fake.failAt = {
      sql: /UPDATE budget_entries SET category_id/i,
      error: "fk_violation",
    };
    const outcome = await applyMigration(plan, 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");

    // The backup is still a valid SREF string from the caller's perspective:
    // nothing in the migration writes to disk. We verify the captured content
    // still parses.
    expect(() => JSON.parse(writtenBackup!)).not.toThrow();
  });
});

// ---------------------------------------------------------------------------
// Flow 5 — restore flow (rollback by SREF import)
// ---------------------------------------------------------------------------

describe("integration: restore from SREF after a migration", () => {
  it("imports the backup, flips schema back to v2, stamps reverted_at", async () => {
    // 1. Run a successful migration so the journal is in place.
    let writtenBackup: string | null = null;
    mockInvoke.mockImplementation(async (cmd, args) => {
      if (cmd === "ensure_backup_dir") return "/tmp/sr-backups";
      if (cmd === "write_export_file") {
        writtenBackup = (args as { content: string }).content;
        return undefined;
      }
      if (cmd === "read_import_file") return writtenBackup ?? "";
      if (cmd === "get_file_size") return 2048;
      if (cmd === "file_exists") return true;
      if (cmd === "is_file_encrypted") return false;
      throw new Error(`unexpected invoke: ${cmd}`);
    });

    const plan = computeMigrationPlan(makeV2Profile());
    const backup = await createPreMigrationBackup({ profile: PROFILE });
    const outcome = await applyMigration(plan, backup);
    expect(outcome.succeeded).toBe(true);

    // 2. Now call restoreFromBackup using the same path.
    const restoreResult = await restoreFromBackup(backup.path, null);
    expect(restoreResult.filePath).toBe(backup.path);
    expect(restoreResult.revertedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);

    // 3. importTransactionsWithCategories must have been called exactly once.
    expect(vi.mocked(importTransactionsWithCategories)).toHaveBeenCalledTimes(1);

    // 4. schema version reset + journal updated with reverted_at.
    expect(fake.preferences.get("categories_schema_version")).toBe("v2");
    const journal = JSON.parse(
      fake.preferences.get("last_categories_migration") as string,
    );
    expect(journal.reverted_at).toBe(restoreResult.revertedAt);
  });
});
