import { useReducer, useCallback, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import Papa from "papaparse";
import type {
  ImportWizardStep,
  ScannedSource,
  ScannedFile,
  SourceConfig,
  ParsedRow,
  DuplicateCheckResult,
  ImportReport,
  ImportSource,
  ImportConfigTemplate,
} from "../shared/types";
import {
  getImportFolder,
  setImportFolder,
} from "../services/userPreferenceService";
import {
  getAllSources,
  getSourceByName,
  createSource,
  updateSource,
} from "../services/importSourceService";
import {
  existsByHash,
  createImportedFile,
  updateFileStatus,
  getFilesBySourceId,
} from "../services/importedFileService";
import {
  insertBatch,
  findDuplicates,
} from "../services/transactionService";
import { categorizeBatch } from "../services/categorizationService";
import {
  getAllTemplates,
  createTemplate,
  updateTemplate,
  deleteTemplate as deleteTemplateService,
} from "../services/importConfigTemplateService";
import {
  preprocessQuotedCSV,
  autoDetectConfig as runAutoDetect,
} from "../utils/csvAutoDetect";
import {
  detectAmountSeparators,
  formatFromRow,
  formatToRow,
  ImportFormatError,
  mapRow,
  ROW_ERROR_KEYS,
} from "../utils/importFormat";

/** Error text for the banner: an i18n key when we have one, the raw message otherwise. */
function errorMessage(e: unknown): string {
  if (e instanceof ImportFormatError) return e.i18nKey;
  return e instanceof Error ? e.message : String(e);
}

interface WizardState {
  step: ImportWizardStep;
  importFolder: string | null;
  scannedSources: ScannedSource[];
  selectedSource: ScannedSource | null;
  selectedFiles: ScannedFile[];
  sourceConfig: SourceConfig;
  existingSource: ImportSource | null;
  parsedPreview: ParsedRow[];
  previewHeaders: string[];
  duplicateResult: DuplicateCheckResult | null;
  excludedDuplicateIndices: Set<number>;
  importReport: ImportReport | null;
  importProgress: { current: number; total: number; file: string };
  isLoading: boolean;
  error: string | null;
  configuredSourceNames: Set<string>;
  importedFilesBySource: Map<string, Set<string>>;
  configTemplates: ImportConfigTemplate[];
  selectedTemplateId: number | null;
}

type WizardAction =
  | { type: "SET_LOADING"; payload: boolean }
  | { type: "SET_ERROR"; payload: string | null }
  | { type: "SET_STEP"; payload: ImportWizardStep }
  | { type: "SET_IMPORT_FOLDER"; payload: string | null }
  | { type: "SET_SCANNED_SOURCES"; payload: ScannedSource[] }
  | { type: "SET_SELECTED_SOURCE"; payload: ScannedSource }
  | { type: "SET_SELECTED_FILES"; payload: ScannedFile[] }
  | { type: "SET_SOURCE_CONFIG"; payload: SourceConfig }
  | { type: "SET_EXISTING_SOURCE"; payload: ImportSource | null }
  | { type: "SET_PARSED_PREVIEW"; payload: { rows: ParsedRow[]; headers: string[] } }
  | { type: "SET_DUPLICATE_RESULT"; payload: DuplicateCheckResult }
  | { type: "TOGGLE_DUPLICATE_ROW"; payload: number }
  | { type: "SET_SKIP_ALL_DUPLICATES"; payload: boolean }
  | { type: "SET_IMPORT_REPORT"; payload: ImportReport }
  | { type: "SET_IMPORT_PROGRESS"; payload: { current: number; total: number; file: string } }
  | { type: "SET_CONFIGURED_SOURCES"; payload: { names: Set<string>; files: Map<string, Set<string>> } }
  | { type: "SET_CONFIG_TEMPLATES"; payload: ImportConfigTemplate[] }
  | { type: "SET_SELECTED_TEMPLATE_ID"; payload: number | null }
  | { type: "RESET" };

const defaultConfig: SourceConfig = {
  name: "",
  delimiter: ";",
  encoding: "utf-8",
  dateFormat: "DD/MM/YYYY",
  skipLines: 0,
  columnMapping: { date: 0, description: 1, amount: 2 },
  amountMode: "single",
  signConvention: "negative_expense",
  hasHeader: true,
};

const initialState: WizardState = {
  step: "source-list",
  importFolder: null,
  scannedSources: [],
  selectedSource: null,
  selectedFiles: [],
  sourceConfig: { ...defaultConfig },
  existingSource: null,
  parsedPreview: [],
  previewHeaders: [],
  duplicateResult: null,
  excludedDuplicateIndices: new Set(),
  importReport: null,
  importProgress: { current: 0, total: 0, file: "" },
  isLoading: false,
  error: null,
  configuredSourceNames: new Set(),
  importedFilesBySource: new Map(),
  configTemplates: [],
  selectedTemplateId: null,
};

function reducer(state: WizardState, action: WizardAction): WizardState {
  switch (action.type) {
    case "SET_LOADING":
      return { ...state, isLoading: action.payload };
    case "SET_ERROR":
      return { ...state, error: action.payload, isLoading: false };
    case "SET_STEP":
      return { ...state, step: action.payload };
    case "SET_IMPORT_FOLDER":
      return { ...state, importFolder: action.payload };
    case "SET_SCANNED_SOURCES":
      return { ...state, scannedSources: action.payload, isLoading: false };
    case "SET_SELECTED_SOURCE":
      return { ...state, selectedSource: action.payload };
    case "SET_SELECTED_FILES":
      return { ...state, selectedFiles: action.payload };
    case "SET_SOURCE_CONFIG":
      return { ...state, sourceConfig: action.payload };
    case "SET_EXISTING_SOURCE":
      return { ...state, existingSource: action.payload };
    case "SET_PARSED_PREVIEW":
      return {
        ...state,
        parsedPreview: action.payload.rows,
        previewHeaders: action.payload.headers,
        isLoading: false,
      };
    case "SET_DUPLICATE_RESULT":
      return {
        ...state,
        duplicateResult: action.payload,
        excludedDuplicateIndices: new Set(action.payload.duplicateRows.map((d) => d.rowIndex)),
        isLoading: false,
      };
    case "TOGGLE_DUPLICATE_ROW": {
      const next = new Set(state.excludedDuplicateIndices);
      if (next.has(action.payload)) {
        next.delete(action.payload);
      } else {
        next.add(action.payload);
      }
      return { ...state, excludedDuplicateIndices: next };
    }
    case "SET_SKIP_ALL_DUPLICATES":
      return {
        ...state,
        excludedDuplicateIndices: action.payload
          ? new Set(state.duplicateResult?.duplicateRows.map((d) => d.rowIndex) ?? [])
          : new Set(),
      };
    case "SET_IMPORT_REPORT":
      return { ...state, importReport: action.payload, isLoading: false };
    case "SET_IMPORT_PROGRESS":
      return { ...state, importProgress: action.payload };
    case "SET_CONFIGURED_SOURCES":
      return {
        ...state,
        configuredSourceNames: action.payload.names,
        importedFilesBySource: action.payload.files,
      };
    case "SET_CONFIG_TEMPLATES":
      return { ...state, configTemplates: action.payload };
    case "SET_SELECTED_TEMPLATE_ID":
      return { ...state, selectedTemplateId: action.payload };
    case "RESET":
      return {
        ...initialState,
        importFolder: state.importFolder,
        scannedSources: state.scannedSources,
        configuredSourceNames: state.configuredSourceNames,
        importedFilesBySource: state.importedFilesBySource,
        configTemplates: state.configTemplates,
      };
    default:
      return state;
  }
}

export function useImportWizard() {
  const [state, dispatch] = useReducer(reducer, initialState);

  // Load import folder on mount
  useEffect(() => {
    (async () => {
      try {
        const folder = await getImportFolder();
        dispatch({ type: "SET_IMPORT_FOLDER", payload: folder });
        if (folder) {
          await scanFolderInternal(folder);
        }
      } catch {
        // No folder configured yet
      }
    })();
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  const loadConfiguredSources = useCallback(async () => {
    const sources = await getAllSources();
    const names = new Set(sources.map((s) => s.name));
    const files = new Map<string, Set<string>>();

    for (const source of sources) {
      const imported = await getFilesBySourceId(source.id);
      files.set(
        source.name,
        new Set(imported.map((f) => f.filename))
      );
    }

    dispatch({ type: "SET_CONFIGURED_SOURCES", payload: { names, files } });

    const templates = await getAllTemplates();
    dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
  }, []);

  const scanFolderInternal = useCallback(
    async (folder: string) => {
      dispatch({ type: "SET_LOADING", payload: true });
      try {
        const sources = await invoke<ScannedSource[]>("scan_import_folder", {
          folderPath: folder,
        });
        dispatch({ type: "SET_SCANNED_SOURCES", payload: sources });
        await loadConfiguredSources();
      } catch (e) {
        dispatch({
          type: "SET_ERROR",
          payload: e instanceof Error ? e.message : String(e),
        });
      }
    },
    [loadConfiguredSources]
  );

  const browseFolder = useCallback(async () => {
    try {
      const folder = await invoke<string | null>("pick_folder");
      if (folder) {
        await setImportFolder(folder);
        dispatch({ type: "SET_IMPORT_FOLDER", payload: folder });
        await scanFolderInternal(folder);
      }
    } catch (e) {
      dispatch({
        type: "SET_ERROR",
        payload: e instanceof Error ? e.message : String(e),
      });
    }
  }, [scanFolderInternal]);

  const refreshFolder = useCallback(async () => {
    if (state.importFolder) {
      await scanFolderInternal(state.importFolder);
    }
  }, [state.importFolder, scanFolderInternal]);

  const selectSource = useCallback(
    async (source: ScannedSource) => {
      // Sort files: new files first, then already-imported
      const importedNames = state.importedFilesBySource.get(source.folder_name);
      const sorted = [...source.files].sort((a, b) => {
        const aImported = importedNames?.has(a.filename) ?? false;
        const bImported = importedNames?.has(b.filename) ?? false;
        if (aImported !== bImported) return aImported ? 1 : -1;
        return a.filename.localeCompare(b.filename);
      });
      const sortedSource = { ...source, files: sorted };

      // Pre-select only new files
      const newFiles = sorted.filter((f) => !importedNames?.has(f.filename));

      dispatch({ type: "SET_SELECTED_SOURCE", payload: sortedSource });
      dispatch({ type: "SET_SELECTED_FILES", payload: newFiles });

      try {
        // Check if this source already has config in DB
        const existing = await getSourceByName(source.folder_name);
        dispatch({ type: "SET_EXISTING_SOURCE", payload: existing });

        // Provenance of the stored format, not the format itself: the template
        // is never re-read, it is only shown as the source's origin. Blindly
        // resetting it to null used to make an already-linked source look
        // unconfigured.
        dispatch({
          type: "SET_SELECTED_TEMPLATE_ID",
          payload: existing?.template_id ?? null,
        });

        let activeDelimiter = defaultConfig.delimiter;
        let activeEncoding = "utf-8";
        let activeSkipLines = 0;
        let activeHasHeader = true;

        // Restore the format as it was SAVED. Nothing is re-inferred and
        // nothing is defaulted here — the amount mode and the sign convention
        // are columns now (v17), and reading them is the whole point of #324.
        let restored: SourceConfig | null = null;
        if (existing) {
          try {
            restored = { name: existing.name, ...formatFromRow(existing) };
          } catch (e) {
            // A stored format we cannot decode is REPORTED, not quietly swapped
            // for a plausible default — that substitution is how amounts got
            // flipped. The wizard then opens on a fresh configuration, because
            // "reconfigure this source" has to be an action the user can take.
            dispatch({ type: "SET_ERROR", payload: errorMessage(e) });
          }
        }

        if (restored) {
          dispatch({ type: "SET_SOURCE_CONFIG", payload: restored });
          activeDelimiter = restored.delimiter;
          activeEncoding = restored.encoding;
          activeSkipLines = restored.skipLines;
          activeHasHeader = restored.hasHeader;
        } else {
          // Auto-detect encoding for first file
          if (source.files.length > 0) {
            try {
              activeEncoding = await invoke<string>("detect_encoding", {
                filePath: source.files[0].file_path,
              });
            } catch {
              // fallback to utf-8
            }
          }

          dispatch({
            type: "SET_SOURCE_CONFIG",
            payload: {
              ...defaultConfig,
              name: source.folder_name,
              encoding: activeEncoding,
            },
          });
        }

        // Load preview headers from first file
        if (source.files.length > 0) {
          await loadHeadersWithConfig(
            source.files[0].file_path,
            activeDelimiter,
            activeEncoding,
            activeSkipLines,
            activeHasHeader
          );
        }

        dispatch({ type: "SET_STEP", payload: "source-config" });
      } catch (e) {
        dispatch({ type: "SET_ERROR", payload: errorMessage(e) });
      }
    },
    [state.importedFilesBySource] // eslint-disable-line react-hooks/exhaustive-deps
  );

  const loadHeadersWithConfig = useCallback(
    async (filePath: string, delimiter: string, encoding: string, skipLines: number, hasHeader: boolean) => {
      try {
        const preview = await invoke<string>("get_file_preview", {
          filePath,
          encoding,
          maxLines: skipLines + 5,
        });
        const preprocessed = preprocessQuotedCSV(preview);
        const parsed = Papa.parse(preprocessed, { delimiter, skipEmptyLines: true });
        const data = parsed.data as string[][];
        const headerRow = hasHeader && data.length > skipLines ? skipLines : -1;
        if (headerRow >= 0 && data[headerRow]) {
          dispatch({
            type: "SET_PARSED_PREVIEW",
            payload: {
              rows: [],
              headers: data[headerRow].map((h) => h.trim()),
            },
          });
        } else if (data.length > 0) {
          // No header row — generate column indices as headers
          const firstDataRow = data[skipLines] || data[0];
          dispatch({
            type: "SET_PARSED_PREVIEW",
            payload: {
              rows: [],
              headers: firstDataRow.map((_, i) => `Col ${i}`),
            },
          });
        }
      } catch {
        // ignore preview errors
      }
    },
    []
  );

  const updateConfig = useCallback(
    (config: SourceConfig) => {
      dispatch({ type: "SET_SOURCE_CONFIG", payload: config });

      // Reload headers when delimiter, encoding, skipLines, or hasHeader changes
      if (state.selectedFiles.length > 0) {
        loadHeadersWithConfig(
          state.selectedFiles[0].file_path,
          config.delimiter,
          config.encoding,
          config.skipLines,
          config.hasHeader
        );
      }
    },
    [state.selectedFiles, loadHeadersWithConfig]
  );

  const toggleFile = useCallback(
    (file: ScannedFile) => {
      const exists = state.selectedFiles.some(
        (f) => f.file_path === file.file_path
      );
      if (exists) {
        dispatch({
          type: "SET_SELECTED_FILES",
          payload: state.selectedFiles.filter(
            (f) => f.file_path !== file.file_path
          ),
        });
      } else {
        dispatch({
          type: "SET_SELECTED_FILES",
          payload: [...state.selectedFiles, file],
        });
      }
    },
    [state.selectedFiles]
  );

  const selectAllFiles = useCallback(() => {
    if (state.selectedSource) {
      const importedNames = state.importedFilesBySource.get(state.selectedSource.folder_name);
      const newFiles = importedNames
        ? state.selectedSource.files.filter((f) => !importedNames.has(f.filename))
        : state.selectedSource.files;
      dispatch({
        type: "SET_SELECTED_FILES",
        payload: newFiles,
      });
    }
  }, [state.selectedSource, state.importedFilesBySource]);

  // Internal helper: parses selected files and returns rows + headers
  const parseFilesInternal = useCallback(async (): Promise<{ rows: ParsedRow[]; headers: string[] }> => {
    const config = state.sourceConfig;
    const allRows: ParsedRow[] = [];
    let headers: string[] = [];

    for (const file of state.selectedFiles) {
      const content = await invoke<string>("read_file_content", {
        filePath: file.file_path,
        encoding: config.encoding,
      });

      const preprocessed = preprocessQuotedCSV(content);

      const parsed = Papa.parse(preprocessed, {
        delimiter: config.delimiter,
        skipEmptyLines: true,
      });

      const data = parsed.data as string[][];
      const startIdx = config.skipLines + (config.hasHeader ? 1 : 0);

      if (config.hasHeader && data.length > config.skipLines) {
        headers = data[config.skipLines].map((h) => h.trim());
      } else if (!config.hasHeader && headers.length === 0 && data.length > config.skipLines) {
        const firstDataRow = data[config.skipLines];
        headers = firstDataRow.map((_, i) => `Col ${i}`);
      }

      // Data rows of THIS file, kept aside so the decimal separator of each
      // amount column is arbitrated over the whole column before any row is
      // read. `1.234` alone is ambiguous; the column is not.
      const dataRows: string[][] = [];
      for (let i = startIdx; i < data.length; i++) {
        const raw = data[i];
        if (raw.length <= 1 && raw[0]?.trim() === "") continue;
        dataRows.push(raw);
      }
      const decimalSeparators = detectAmountSeparators(dataRows, config);

      for (const raw of dataRows) {
        try {
          allRows.push(
            mapRow(raw, config, {
              rowIndex: allRows.length,
              sourceFilename: file.filename,
              decimalSeparators,
            })
          );
        } catch {
          allRows.push({
            rowIndex: allRows.length,
            raw,
            parsed: null,
            error: ROW_ERROR_KEYS.parseError,
            sourceFilename: file.filename,
          });
        }
      }
    }

    return { rows: allRows, headers };
  }, [state.selectedFiles, state.sourceConfig]);

  // Parse files and store preview (does NOT change wizard step)
  const parsePreview = useCallback(async () => {
    if (state.selectedFiles.length === 0) return;

    dispatch({ type: "SET_LOADING", payload: true });
    dispatch({ type: "SET_ERROR", payload: null });

    try {
      const result = await parseFilesInternal();
      dispatch({
        type: "SET_PARSED_PREVIEW",
        payload: result,
      });
    } catch (e) {
      dispatch({
        type: "SET_ERROR",
        payload: e instanceof Error ? e.message : String(e),
      });
    }
  }, [state.selectedFiles, parseFilesInternal]);

  // Internal helper: runs duplicate checking against parsed rows.
  //
  // Deliberately writes NOTHING. The source config used to be saved here, so an
  // import abandoned at the duplicate step still left a — possibly wrong —
  // configuration behind. It is written by `executeImport` now (#324).
  const checkDuplicatesInternal = useCallback(async (parsedRows: ParsedRow[]) => {
    // Check file-level duplicates (check ALL selected files, not just the first)
    let fileAlreadyImported = false;
    let existingFileId: number | undefined;

    for (const file of state.selectedFiles) {
      const hash = await invoke<string>("hash_file", {
        filePath: file.file_path,
      });
      const existing = await existsByHash(hash);
      if (existing) {
        fileAlreadyImported = true;
        existingFileId = existing.id;
        break;
      }
    }

    // Check row-level duplicates against DB
    const validRows = parsedRows.filter((r) => r.parsed);
    const duplicateMatches = await findDuplicates(
      validRows.map((r) => ({
        date: r.parsed!.date,
        description: r.parsed!.description,
        amount: r.parsed!.amount,
      }))
    );

    const dbDuplicateIndices = new Set(duplicateMatches.map((d) => d.rowIndex));
    const duplicateRows = duplicateMatches.map((d) => ({
      rowIndex: d.rowIndex,
      date: d.date,
      description: d.description,
      amount: d.amount,
      existingTransactionId: d.existingTransactionId,
    }));

    // Cross-file duplicate detection: find rows that appear in multiple source files
    const seenKeys = new Map<string, number>(); // key → first-seen validRows index
    for (let i = 0; i < validRows.length; i++) {
      if (dbDuplicateIndices.has(i)) continue; // already flagged as DB duplicate
      const row = validRows[i];
      const key = `${row.parsed!.date}|${row.parsed!.description}|${row.parsed!.amount}`;
      const firstIdx = seenKeys.get(key);
      if (firstIdx !== undefined) {
        // Only flag as cross-file duplicate if rows come from different files
        if (validRows[firstIdx].sourceFilename !== row.sourceFilename) {
          duplicateRows.push({
            rowIndex: i,
            date: row.parsed!.date,
            description: row.parsed!.description,
            amount: row.parsed!.amount,
            existingTransactionId: -1, // signals "within batch" in the UI
          });
          dbDuplicateIndices.add(i);
        }
      } else {
        seenKeys.set(key, i);
      }
    }

    const newRows = validRows.filter(
      (_, i) => !dbDuplicateIndices.has(i)
    );

    dispatch({
      type: "SET_DUPLICATE_RESULT",
      payload: {
        fileAlreadyImported,
        existingFileId,
        duplicateRows,
        newRows,
      },
    });
    dispatch({ type: "SET_STEP", payload: "duplicate-check" });
  }, [state.selectedFiles]);

  // Check duplicates using already-parsed preview data
  const checkDuplicates = useCallback(async () => {
    dispatch({ type: "SET_LOADING", payload: true });
    dispatch({ type: "SET_ERROR", payload: null });

    try {
      await checkDuplicatesInternal(state.parsedPreview);
    } catch (e) {
      dispatch({
        type: "SET_ERROR",
        payload: e instanceof Error ? e.message : String(e),
      });
    }
  }, [state.parsedPreview, checkDuplicatesInternal]);

  // Parse files then check duplicates in one step (skips preview step)
  const parseAndCheckDuplicates = useCallback(async () => {
    if (state.selectedFiles.length === 0) return;

    dispatch({ type: "SET_LOADING", payload: true });
    dispatch({ type: "SET_ERROR", payload: null });

    try {
      const result = await parseFilesInternal();
      dispatch({
        type: "SET_PARSED_PREVIEW",
        payload: result,
      });
      await checkDuplicatesInternal(result.rows);
    } catch (e) {
      dispatch({
        type: "SET_ERROR",
        payload: e instanceof Error ? e.message : String(e),
      });
    }
  }, [state.selectedFiles, parseFilesInternal, checkDuplicatesInternal]);

  const executeImport = useCallback(async () => {
    if (!state.duplicateResult) return;

    dispatch({ type: "SET_STEP", payload: "importing" });
    dispatch({ type: "SET_ERROR", payload: null });

    try {
      const config = state.sourceConfig;

      // Persist the format — the ONLY write point. It happens here rather than
      // at the duplicate step so an abandoned import leaves no configuration
      // behind, and it goes through `formatToRow` so no field can be dropped.
      // It has to precede the file records, which carry a `source_id` FK.
      const formatRow = formatToRow(config);
      let sourceId: number;
      if (state.existingSource) {
        sourceId = state.existingSource.id;
        await updateSource(sourceId, {
          name: config.name,
          ...formatRow,
          template_id: state.selectedTemplateId,
        });
      } else {
        sourceId = await createSource({
          name: config.name,
          ...formatRow,
          template_id: state.selectedTemplateId,
        });
      }

      // Determine rows to import: new rows + non-excluded duplicates
      const includedDuplicates = state.duplicateResult.duplicateRows
        .filter((d) => !state.excludedDuplicateIndices.has(d.rowIndex));
      const rowsToImport = [
        ...state.duplicateResult.newRows,
        ...state.parsedPreview.filter(
          (r) =>
            r.parsed &&
            includedDuplicates.some((d) => d.rowIndex === r.rowIndex)
        ),
      ];

      const validRows = rowsToImport.filter((r) => r.parsed);
      const totalRows = validRows.length;

      dispatch({
        type: "SET_IMPORT_PROGRESS",
        payload: { current: 0, total: totalRows, file: state.selectedFiles[0]?.filename || "" },
      });

      // Create one imported_files record per file
      const fileIdMap = new Map<string, number>();
      for (const file of state.selectedFiles) {
        const hash = await invoke<string>("hash_file", {
          filePath: file.file_path,
        });
        const rowCount = validRows.filter((r) => r.sourceFilename === file.filename).length;
        const fId = await createImportedFile({
          source_id: sourceId,
          filename: file.filename,
          file_hash: hash,
          row_count: rowCount,
          status: "completed",
        });
        fileIdMap.set(file.filename, fId);
      }

      // Auto-categorize
      const descriptions = validRows.map((r) => r.parsed!.description);
      const categorizations = await categorizeBatch(descriptions);

      let categorizedCount = 0;
      let uncategorizedCount = 0;
      const errors: Array<{ rowIndex: number; message: string }> = [];

      // Build transaction records
      const transactions = validRows.map((row, i) => {
        const cat = categorizations[i];
        if (cat.category_id) {
          categorizedCount++;
        } else {
          uncategorizedCount++;
        }
        return {
          date: row.parsed!.date,
          description: row.parsed!.description,
          amount: row.parsed!.amount,
          source_id: sourceId,
          file_id: fileIdMap.get(row.sourceFilename || "") ?? 0,
          original_description: row.raw.join(config.delimiter),
          category_id: cat.category_id,
          supplier_id: cat.supplier_id,
        };
      });

      // Insert with progress
      let importedCount = 0;
      try {
        importedCount = await insertBatch(transactions, (inserted) => {
          const currentFile = validRows[inserted - 1]?.sourceFilename || "";
          dispatch({
            type: "SET_IMPORT_PROGRESS",
            payload: { current: inserted, total: totalRows, file: currentFile },
          });
        });

        dispatch({
          type: "SET_IMPORT_PROGRESS",
          payload: { current: importedCount, total: totalRows, file: "done" },
        });
      } catch (e) {
        // Update status on all file records on error
        for (const fId of fileIdMap.values()) {
          await updateFileStatus(fId, "error", 0, String(e));
        }
        errors.push({
          rowIndex: 0,
          message: e instanceof Error ? e.message : String(e),
        });
      }

      // Count errors from parsing
      const parseErrors = state.parsedPreview.filter((r) => r.error);
      for (const err of parseErrors) {
        errors.push({
          rowIndex: err.rowIndex,
          message: err.error || ROW_ERROR_KEYS.parseError,
        });
      }

      const report: ImportReport = {
        totalRows: state.parsedPreview.length,
        importedCount,
        skippedDuplicates: state.excludedDuplicateIndices.size,
        errorCount: errors.length,
        categorizedCount,
        uncategorizedCount,
        errors,
      };

      dispatch({ type: "SET_IMPORT_REPORT", payload: report });
      dispatch({ type: "SET_STEP", payload: "report" });

      // Refresh configured sources
      await loadConfiguredSources();
    } catch (e) {
      dispatch({
        type: "SET_ERROR",
        payload: e instanceof Error ? e.message : String(e),
      });
      dispatch({ type: "SET_STEP", payload: "confirm" });
    }
  }, [
    state.duplicateResult,
    state.sourceConfig,
    state.existingSource,
    state.selectedTemplateId,
    state.excludedDuplicateIndices,
    state.parsedPreview,
    state.selectedFiles,
    loadConfiguredSources,
  ]);

  const goToStep = useCallback((step: ImportWizardStep) => {
    dispatch({ type: "SET_STEP", payload: step });
  }, []);

  const reset = useCallback(() => {
    dispatch({ type: "RESET" });
  }, []);

  const autoDetectConfig = useCallback(async () => {
    if (state.selectedFiles.length === 0) return;

    dispatch({ type: "SET_LOADING", payload: true });
    dispatch({ type: "SET_ERROR", payload: null });

    try {
      const content = await invoke<string>("read_file_content", {
        filePath: state.selectedFiles[0].file_path,
        encoding: state.sourceConfig.encoding,
      });

      const result = runAutoDetect(content);

      if (result) {
        const newConfig = {
          ...state.sourceConfig,
          delimiter: result.delimiter,
          hasHeader: result.hasHeader,
          skipLines: result.skipLines,
          dateFormat: result.dateFormat,
          columnMapping: result.columnMapping,
          amountMode: result.amountMode,
          signConvention: result.signConvention,
        };
        dispatch({ type: "SET_SOURCE_CONFIG", payload: newConfig });
        dispatch({ type: "SET_LOADING", payload: false });

        // Refresh column headers with new config
        await loadHeadersWithConfig(
          state.selectedFiles[0].file_path,
          newConfig.delimiter,
          newConfig.encoding,
          newConfig.skipLines,
          newConfig.hasHeader
        );
      } else {
        dispatch({
          type: "SET_ERROR",
          payload: "Auto-detection failed. Please configure manually.",
        });
      }
    } catch (e) {
      dispatch({
        type: "SET_ERROR",
        payload: e instanceof Error ? e.message : String(e),
      });
    }
  }, [state.selectedFiles, state.sourceConfig, loadHeadersWithConfig]);

  const saveConfigAsTemplate = useCallback(async (name: string) => {
    await createTemplate({ name, ...formatToRow(state.sourceConfig) });
    const templates = await getAllTemplates();
    dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
  }, [state.sourceConfig]);

  const applyConfigTemplate = useCallback((templateId: number) => {
    const template = state.configTemplates.find((t) => t.id === templateId);
    if (!template) return;

    let newConfig: SourceConfig;
    try {
      newConfig = {
        name: state.sourceConfig.name,
        ...formatFromRow(template),
      };
    } catch (e) {
      dispatch({ type: "SET_ERROR", payload: errorMessage(e) });
      return;
    }

    dispatch({ type: "SET_SOURCE_CONFIG", payload: newConfig });
    // Applying a template COPIES its format onto the source. The id recorded
    // here is provenance only — the copy is what the next import reads.
    dispatch({ type: "SET_SELECTED_TEMPLATE_ID", payload: templateId });

    // Reload headers with new config
    if (state.selectedFiles.length > 0) {
      loadHeadersWithConfig(
        state.selectedFiles[0].file_path,
        newConfig.delimiter,
        newConfig.encoding,
        newConfig.skipLines,
        newConfig.hasHeader
      );
    }
  }, [state.configTemplates, state.sourceConfig.name, state.selectedFiles, loadHeadersWithConfig]);

  const updateConfigTemplate = useCallback(async () => {
    if (!state.selectedTemplateId) return;
    const template = state.configTemplates.find((t) => t.id === state.selectedTemplateId);
    if (!template) return;
    // Writes to `import_config_templates` only: a source configured from this
    // template keeps its own eight columns and is untouched.
    await updateTemplate(state.selectedTemplateId, {
      name: template.name,
      ...formatToRow(state.sourceConfig),
    });
    const templates = await getAllTemplates();
    dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
  }, [state.selectedTemplateId, state.configTemplates, state.sourceConfig]);

  const deleteConfigTemplate = useCallback(async (id: number) => {
    await deleteTemplateService(id);
    if (state.selectedTemplateId === id) {
      dispatch({ type: "SET_SELECTED_TEMPLATE_ID", payload: null });
    }
    const templates = await getAllTemplates();
    dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
  }, [state.selectedTemplateId]);

  return {
    state,
    browseFolder,
    refreshFolder,
    selectSource,
    updateConfig,
    toggleFile,
    selectAllFiles,
    parsePreview,
    checkDuplicates,
    parseAndCheckDuplicates,
    executeImport,
    goToStep,
    reset,
    autoDetectConfig,
    saveConfigAsTemplate,
    applyConfigTemplate,
    updateConfigTemplate,
    deleteConfigTemplate,
    toggleDuplicateRow: (index: number) =>
      dispatch({ type: "TOGGLE_DUPLICATE_ROW", payload: index }),
    setSkipAllDuplicates: (skipAll: boolean) =>
      dispatch({ type: "SET_SKIP_ALL_DUPLICATES", payload: skipAll }),
  };
}
