import type { FastifyInstance } from 'fastify';
import type { RawData, WebSocket } from 'ws';
import websocketPlugin from '@fastify/websocket';
import {
  HapoalimForeignTransactionsBusiness,
  HapoalimForeignTransactionsPersonal,
} from '@accounter/modern-poalim-scraper';
import type { SourceType } from '../shared/source-types.js';
import { ClientMessageSchema, type ServerMessage } from '../shared/ws-protocol.js';
import { registerDiscoveredAccounts } from './account-discovery.js';
import { checkAccounts, type ValidatedPayload } from './check-accounts.js';
import { filterPayload, type FilterableCreds } from './filter-payload.js';
import { ChangedTransaction, InsertedTransactionSummary } from './gql/index.js';
import { createUploadClient, ScraperUploadResult, type UploadClient } from './graphql/client.js';
import { appendRun } from './history.js';
import { OtpManager } from './otp-manager.js';
import type { CalPayload } from './payload-schemas/cal.schema.js';
import type { DiscountPayload } from './payload-schemas/discount.schema.js';
import type { MaxPayload } from './payload-schemas/max.schema.js';
import { BlockedError, ERR_RUN_IN_PROGRESS, startRun, type ScrapeTask } from './scrape-runner.js';
import { scrapeAmex, type MonthlyIsracardPayload as MonthlyAmexPayload } from './scrapers/amex.js';
import { scrapeCal } from './scrapers/cal.js';
import { scrapeCurrencyRates } from './scrapers/currency-rates.js';
import { scrapeDiscount } from './scrapers/discount.js';
import {
  countIsracardTransactions,
  scrapeIsracard,
  type MonthlyIsracardPayload,
} from './scrapers/isracard.js';
import { scrapeMax } from './scrapers/max.js';
import { scrapeOtsarHahayal } from './scrapers/otsar-hahayal.js';
import { scrapePoalim } from './scrapers/poalim.js';
import { getVault, isLocked } from './vault-store.js';
import type { Vault } from './vault.js';

function send(socket: WebSocket, msg: ServerMessage): void {
  socket.send(JSON.stringify(msg));
}

type SourceRef = { id: string; type: SourceType };

type HapoalimForeignTransactions =
  | HapoalimForeignTransactionsPersonal
  | HapoalimForeignTransactionsBusiness;

function collectSourceRefs(vault: Vault): SourceRef[] {
  return [
    ...vault.poalimAccounts.map(a => ({ id: a.id, type: 'poalim' as const })),
    ...vault.discountAccounts.map(a => ({ id: a.id, type: 'discount' as const })),
    ...vault.isracardAccounts.map(a => ({ id: a.id, type: 'isracard' as const })),
    ...vault.amexAccounts.map(a => ({ id: a.id, type: 'amex' as const })),
    ...vault.calAccounts.map(a => ({ id: a.id, type: 'cal' as const })),
    ...vault.maxAccounts.map(a => ({ id: a.id, type: 'max' as const })),
    ...vault.otsarHahayalAccounts.map(a => ({ id: a.id, type: 'otsar-hahayal' as const })),
  ];
}

function buildTask(
  src: SourceRef,
  vault: Vault,
  emit: (msg: ServerMessage) => void,
  otpManager: OtpManager,
  uploadClient: UploadClient | null,
  dateFrom: Date,
  dateTo: Date,
): ScrapeTask {
  return {
    sourceId: src.id,
    nickname: src.id,
    type: src.type,
    run: async () => {
      const headless = !vault.settings.showBrowser;
      if (src.type === 'otsar-hahayal') {
        try {
          const creds = vault.otsarHahayalAccounts.find(a => a.id === src.id);
          if (!creds) throw new Error(`Otsar HaHayal account ${src.id} not found in vault`);

          const { ilsData, foreignData, creditCardData } = await scrapeOtsarHahayal(
            creds,
            dateFrom,
            dateTo,
            headless,
            emit,
          );

          // Register discovered accounts and check they're approved
          // Merge ILS and foreign accounts — both are identified by account-branch
          const foreignAccountEntries = foreignData.map(d => ({
            account: `${String(d.metadata.account)}-${d.metadata.subAccount}`,
            branch: String(d.metadata.branch),
          }));
          const accountPayload = [
            ...ilsData.map(d => ({ account: d.account.account, branch: d.account.branch })),
            ...foreignAccountEntries,
          ];
          await registerDiscoveredAccounts('otsar-hahayal', creds.id, [accountPayload]);
          const check = checkAccounts('otsar-hahayal', accountPayload, getVault().accountRecords);
          if (check.unknown.length > 0) {
            emit({
              type: 'task-blocked',
              sourceId: src.id,
              sourceType: 'otsar-hahayal',
              unknownAccounts: check.unknown,
            });
            throw new BlockedError(check.unknown);
          }

          // Filter out ignored bank accounts before uploading
          const ignoredAccountIds = new Set(check.ignored);
          const acceptedIlsData = ilsData.filter(
            d => !ignoredAccountIds.has(`${d.account.branch}-${d.account.account}`),
          );
          const acceptedForeignData = foreignData.filter(
            d =>
              !ignoredAccountIds.has(
                `${d.metadata.branch}-${d.metadata.account}-${d.metadata.subAccount}`,
              ),
          );

          // Register and validate credit cards separately
          const creditCardPayload = creditCardData.map(d => ({ card: d.card }));
          let acceptedCreditCardData = creditCardData;
          if (creditCardPayload.length > 0) {
            await registerDiscoveredAccounts('otsar-hahayal-credit-card', creds.id, [
              creditCardPayload,
            ]);
            const ccCheck = checkAccounts(
              'otsar-hahayal-credit-card',
              creditCardPayload,
              getVault().accountRecords,
            );
            if (ccCheck.unknown.length > 0) {
              emit({
                type: 'task-blocked',
                sourceId: src.id,
                sourceType: 'otsar-hahayal',
                unknownAccounts: ccCheck.unknown,
              });
              throw new BlockedError(ccCheck.unknown);
            }
            // Filter out ignored credit cards before uploading
            const ignoredCards = new Set(ccCheck.ignored);
            acceptedCreditCardData = creditCardData.filter(
              d => !ignoredCards.has(d.card.maskedPan),
            );
          }

          if (!uploadClient)
            return {
              inserted: 0,
              skipped: 0,
              insertedIds: [],
              insertedTransactions: [],
              changedTransactions: [],
            };

          let totalInserted = 0;
          let totalSkipped = 0;
          const allIds: string[] = [];
          const insertedTransactions: InsertedTransactionSummary[] = [];
          const changedTransactions: ChangedTransaction[] = [];

          for (const d of acceptedIlsData) {
            const accountId = `${d.account.branch}-${d.account.account}`;
            emit({
              type: 'task-account-txns-uploading',
              sourceId: src.id,
              accountId,
              txnType: 'ils',
              count: d.transactions.length,
            });
            const r = await uploadClient.uploadOtsarIls([d]);
            emit({
              type: 'task-account-txns-done',
              sourceId: src.id,
              accountId,
              txnType: 'ils',
              inserted: r.inserted,
              skipped: r.skipped,
            });
            totalInserted += r.inserted;
            totalSkipped += r.skipped;
            allIds.push(...r.insertedIds);
            insertedTransactions.push(...(r.insertedTransactions ?? []));
            changedTransactions.push(...(r.changedTransactions ?? []));
          }

          for (const d of acceptedForeignData) {
            const accountId = `${d.metadata.branch}-${d.metadata.account}-${d.metadata.subAccount}`;
            emit({
              type: 'task-account-txns-uploading',
              sourceId: src.id,
              accountId,
              txnType: 'foreign',
              count: d.transactions.length,
            });
            const rf = await uploadClient.uploadOtsarForeign([d]);
            emit({
              type: 'task-account-txns-done',
              sourceId: src.id,
              accountId,
              txnType: 'foreign',
              inserted: rf.inserted,
              skipped: rf.skipped,
            });
            totalInserted += rf.inserted;
            totalSkipped += rf.skipped;
            allIds.push(...rf.insertedIds);
            insertedTransactions.push(...(rf.insertedTransactions ?? []));
            changedTransactions.push(...(rf.changedTransactions ?? []));
          }

          if (acceptedCreditCardData.length > 0) {
            const ccCount = acceptedCreditCardData.reduce((s, d) => s + d.transactions.length, 0);
            emit({
              type: 'task-month-uploading',
              sourceId: src.id,
              month: 'credit-cards',
              transactionCount: ccCount,
            });
            const rc = await uploadClient.uploadOtsarCreditCard(acceptedCreditCardData);
            emit({
              type: 'task-month-uploaded',
              sourceId: src.id,
              month: 'credit-cards',
              inserted: rc.inserted,
              skipped: rc.skipped,
            });
            totalInserted += rc.inserted;
            totalSkipped += rc.skipped;
            allIds.push(...rc.insertedIds);
            insertedTransactions.push(...(rc.insertedTransactions ?? []));
            changedTransactions.push(...(rc.changedTransactions ?? []));
          }

          return {
            inserted: totalInserted,
            skipped: totalSkipped,
            insertedIds: allIds,
            insertedTransactions,
            changedTransactions,
          };
        } catch (err) {
          console.error({ err }, '[task] Otsar HaHayal scrape error');
          throw err;
        }
      }

      if (src.type === 'poalim') {
        try {
          const creds = vault.poalimAccounts.find(a => a.id === src.id);
          if (!creds) throw new Error(`Poalim account ${src.id} not found in vault`);
          const accountsData = await scrapePoalim(
            creds,
            dateFrom,
            dateTo,
            headless,
            otpManager,
            emit,
          );

          if (!uploadClient)
            return {
              inserted: 0,
              skipped: 0,
              insertedIds: [],
              insertedTransactions: [],
              changedTransactions: [],
            };

          let totalInserted = 0;
          let totalSkipped = 0;
          const allIds: string[] = [];
          const insertedTransactions: InsertedTransactionSummary[] = [];
          const changedTransactions: ChangedTransaction[] = [];

          for (const accountData of accountsData) {
            const accountId = `${accountData.bankAccount.branchNumber}-${accountData.bankAccount.accountNumber}`;

            const ilsPayload = accountData.ils;
            if (ilsPayload) {
              const pendingIlsTxns =
                ilsPayload.transactions?.filter(
                  t => t.transactionType === 'TODAY' || t.transactionType === 'FUTURE',
                ) ?? [];
              for (const t of pendingIlsTxns) {
                console.info(
                  `[poalim] Skipping ${t.transactionType} ILS transaction: ${t.activityDescription}, ILS ${t.eventAmount}, account ${ilsPayload.retrievalTransactionData.accountNumber}`,
                );
              }
              const filteredIlsPayload = {
                ...ilsPayload,
                transactions:
                  ilsPayload.transactions?.filter(
                    t => t.transactionType !== 'TODAY' && t.transactionType !== 'FUTURE',
                  ) ?? [],
              };
              const ilsCount = filteredIlsPayload.transactions.length;
              emit({
                type: 'task-account-txns-uploading',
                sourceId: src.id,
                accountId,
                txnType: 'ils',
                count: ilsCount,
              });
              const r = await uploadClient.uploadPoalimIls(filteredIlsPayload);
              emit({
                type: 'task-account-txns-done',
                sourceId: src.id,
                accountId,
                txnType: 'ils',
                inserted: r.inserted,
                skipped: r.skipped + pendingIlsTxns.length,
              });
              totalInserted += r.inserted;
              totalSkipped += r.skipped + pendingIlsTxns.length;
              allIds.push(...r.insertedIds);
              insertedTransactions.push(...(r.insertedTransactions ?? []));
              changedTransactions.push(...(r.changedTransactions ?? []));
            }

            const foreignPayload = accountData.foreign;
            if (foreignPayload) {
              let foreignPendingCount = 0;
              const filteredForeignPayload = {
                ...foreignPayload,
                balancesAndLimitsDataList: foreignPayload.balancesAndLimitsDataList.map(entry => {
                  const pending = entry.transactions.filter(
                    t => t.transactionType === 'TODAY' || t.transactionType === 'FUTURE',
                  );
                  for (const t of pending) {
                    console.info(
                      `[poalim] Skipping ${t.transactionType} foreign transaction: ${t.activityDescription}, account ${accountData.bankAccount.accountNumber}`,
                    );
                  }
                  foreignPendingCount += pending.length;
                  return {
                    ...entry,
                    transactions: entry.transactions.filter(
                      t => t.transactionType !== 'TODAY' && t.transactionType !== 'FUTURE',
                    ),
                  };
                }),
              } as HapoalimForeignTransactions;
              const foreignCount = filteredForeignPayload.balancesAndLimitsDataList.reduce(
                (sum, e) => sum + e.transactions.length,
                0,
              );
              emit({
                type: 'task-account-txns-uploading',
                sourceId: src.id,
                accountId,
                txnType: 'foreign',
                count: foreignCount,
              });
              const rf = await uploadClient.uploadPoalimForeign(
                filteredForeignPayload,
                accountData.bankAccount,
              );
              emit({
                type: 'task-account-txns-done',
                sourceId: src.id,
                accountId,
                txnType: 'foreign',
                inserted: rf.inserted,
                skipped: rf.skipped + foreignPendingCount,
              });
              totalInserted += rf.inserted;
              totalSkipped += rf.skipped + foreignPendingCount;
              allIds.push(...rf.insertedIds);
              insertedTransactions.push(...(rf.insertedTransactions ?? []));
              changedTransactions.push(...(rf.changedTransactions ?? []));
            }

            const swiftPayload = accountData.swift;
            if (swiftPayload) {
              const swiftCount =
                (swiftPayload as { swiftsList?: unknown[] }).swiftsList?.length ?? 0;
              emit({
                type: 'task-account-txns-uploading',
                sourceId: src.id,
                accountId,
                txnType: 'swift',
                count: swiftCount,
              });
              const rs = await uploadClient.uploadPoalimSwift(
                swiftPayload,
                accountData.bankAccount,
              );
              emit({
                type: 'task-account-txns-done',
                sourceId: src.id,
                accountId,
                txnType: 'swift',
                inserted: rs.inserted,
                skipped: rs.skipped,
              });
              totalInserted += rs.inserted;
              totalSkipped += rs.skipped;
              allIds.push(...rs.insertedIds);
              insertedTransactions.push(...(rs.insertedTransactions ?? []));
              changedTransactions.push(...(rs.changedTransactions ?? []));
            }
          }
          return {
            inserted: totalInserted,
            skipped: totalSkipped,
            insertedIds: allIds,
            insertedTransactions,
            changedTransactions,
          };
        } catch (err) {
          console.error({ err }, '[task] Poalim scrape error');
          throw err;
        }
      }

      // isracard/amex return one payload per month; other scrapers return a single payload
      let isracardPayloads: (MonthlyIsracardPayload | MonthlyAmexPayload)[] | null = null;
      let payloads: ValidatedPayload[];

      switch (src.type) {
        case 'isracard': {
          const creds = vault.isracardAccounts.find(a => a.id === src.id);
          if (!creds) throw new Error(`Isracard account ${src.id} not found in vault`);
          isracardPayloads = await scrapeIsracard(creds, dateFrom, dateTo, emit, headless);
          payloads = isracardPayloads.map(p => p.data);
          break;
        }
        case 'amex': {
          const creds = vault.amexAccounts.find(a => a.id === src.id);
          if (!creds) throw new Error(`Amex account ${src.id} not found in vault`);
          isracardPayloads = await scrapeAmex(creds, dateFrom, dateTo, emit, headless);
          payloads = isracardPayloads.map(p => p.data);
          break;
        }
        case 'cal': {
          const creds = vault.calAccounts.find(a => a.id === src.id);
          if (!creds) throw new Error(`Cal account ${src.id} not found in vault`);
          payloads = [await scrapeCal(creds, dateFrom, dateTo, emit, headless)];
          break;
        }
        case 'discount': {
          const creds = vault.discountAccounts.find(a => a.id === src.id);
          if (!creds) throw new Error(`Discount account ${src.id} not found in vault`);
          payloads = [await scrapeDiscount(creds, dateFrom, dateTo, emit, headless)];
          break;
        }
        case 'max': {
          const creds = vault.maxAccounts.find(a => a.id === src.id);
          if (!creds) throw new Error(`Max account ${src.id} not found in vault`);
          payloads = [await scrapeMax(creds, dateFrom, dateTo, emit, headless)];
          break;
        }
        default:
          throw new Error(`Unhandled source type: ${src.type}`);
      }

      // Use the first non-empty payload to check for unknown accounts (card identifiers
      // are stable across months for isracard/amex, so any month suffices)
      const representativePayload = payloads[0];
      if (!representativePayload)
        return {
          inserted: 0,
          skipped: 0,
          insertedIds: [],
          insertedTransactions: [],
          changedTransactions: [],
        };

      // Apply per-source accepted/ignored filter after validation, before account check
      let creds: FilterableCreds | undefined;
      switch (src.type) {
        case 'isracard':
          creds = vault.isracardAccounts.find(a => a.id === src.id);
          break;
        case 'amex':
          creds = vault.amexAccounts.find(a => a.id === src.id);
          break;
        case 'cal':
          creds = vault.calAccounts.find(a => a.id === src.id);
          break;
        case 'max':
          creds = vault.maxAccounts.find(a => a.id === src.id);
          break;
        case 'discount':
          creds = vault.discountAccounts.find(a => a.id === src.id);
          break;
      }

      if (creds) {
        payloads = payloads.map(p => filterPayload(src.type, p, creds!) as ValidatedPayload);
        isracardPayloads &&= isracardPayloads.map((p, i) => ({
          ...p,
          data: payloads[i] as (typeof p)['data'],
        }));
      }

      // Register any newly discovered accounts as pending, then re-read to avoid stale closure
      await registerDiscoveredAccounts(src.type, src.id, payloads);
      const check = checkAccounts(src.type, payloads[0]!, getVault().accountRecords);
      if (check.unknown.length > 0) {
        emit({
          type: 'task-blocked',
          sourceId: src.id,
          sourceType: src.type,
          unknownAccounts: check.unknown,
        });
        throw new BlockedError(check.unknown);
      }

      if (!uploadClient)
        return {
          inserted: 0,
          skipped: 0,
          insertedIds: [],
          insertedTransactions: [],
          changedTransactions: [],
        };

      // Upload all accepted payloads
      let totalInserted = 0;
      let totalSkipped = 0;
      const allIds: string[] = [];
      const changedTransactions: ChangedTransaction[] = [];
      const insertedTransactions: InsertedTransactionSummary[] = [];

      if ((src.type === 'isracard' || src.type === 'amex') && isracardPayloads) {
        for (const { month, data: monthData } of isracardPayloads) {
          const txnCount = countIsracardTransactions(monthData);
          emit({
            type: 'task-month-uploading',
            sourceId: src.id,
            month,
            transactionCount: txnCount,
          });
          let result: ScraperUploadResult;
          if (src.type === 'isracard') {
            result = await uploadClient.uploadIsracard([monthData]);
          } else {
            result = await uploadClient.uploadAmex([monthData]);
          }
          emit({
            type: 'task-month-uploaded',
            sourceId: src.id,
            month,
            inserted: result.inserted,
            skipped: result.skipped,
          });
          totalInserted += result.inserted;
          totalSkipped += result.skipped;
          allIds.push(...result.insertedIds);
          changedTransactions.push(...(result.changedTransactions ?? []));
          insertedTransactions.push(...(result.insertedTransactions ?? []));
        }
      } else {
        for (const payload of payloads) {
          let result: ScraperUploadResult;
          switch (src.type) {
            case 'cal': {
              for (const entry of payload as CalPayload) {
                emit({
                  type: 'task-month-uploading',
                  sourceId: src.id,
                  month: entry.month,
                  transactionCount: entry.transactions.length,
                });
                result = await uploadClient.uploadCal([entry]);
                emit({
                  type: 'task-month-uploaded',
                  sourceId: src.id,
                  month: entry.month,
                  inserted: result.inserted,
                  skipped: result.skipped,
                });
                totalInserted += result.inserted;
                totalSkipped += result.skipped;
                allIds.push(...result.insertedIds);
                changedTransactions.push(...(result.changedTransactions ?? []));
                insertedTransactions.push(...(result.insertedTransactions ?? []));
              }
              result = {
                inserted: 0,
                skipped: 0,
                insertedIds: [],
                changedTransactions: [],
                insertedTransactions: [],
              };
              break;
            }
            case 'discount': {
              for (const entry of payload as DiscountPayload) {
                emit({
                  type: 'task-month-uploading',
                  sourceId: src.id,
                  month: entry.month,
                  transactionCount: entry.transactions.length,
                });
                result = await uploadClient.uploadDiscount([entry]);
                emit({
                  type: 'task-month-uploaded',
                  sourceId: src.id,
                  month: entry.month,
                  inserted: result.inserted,
                  skipped: result.skipped,
                });
                totalInserted += result.inserted;
                totalSkipped += result.skipped;
                allIds.push(...result.insertedIds);
                changedTransactions.push(...(result.changedTransactions ?? []));
                insertedTransactions.push(...(result.insertedTransactions ?? []));
              }
              result = {
                inserted: 0,
                skipped: 0,
                insertedIds: [],
                changedTransactions: [],
                insertedTransactions: [],
              };
              break;
            }
            case 'max': {
              for (const account of payload as MaxPayload) {
                emit({
                  type: 'task-month-uploading',
                  sourceId: src.id,
                  month: account.accountNumber,
                  transactionCount: account.txns.length,
                });
                result = await uploadClient.uploadMax([account]);
                emit({
                  type: 'task-month-uploaded',
                  sourceId: src.id,
                  month: account.accountNumber,
                  inserted: result.inserted,
                  skipped: result.skipped,
                });
                totalInserted += result.inserted;
                totalSkipped += result.skipped;
                allIds.push(...result.insertedIds);
                changedTransactions.push(...(result.changedTransactions ?? []));
                insertedTransactions.push(...(result.insertedTransactions ?? []));
              }
              result = {
                inserted: 0,
                skipped: 0,
                insertedIds: [],
                changedTransactions: [],
                insertedTransactions: [],
              };
              break;
            }
            default:
              result = {
                inserted: 0,
                skipped: 0,
                insertedIds: [],
                changedTransactions: [],
                insertedTransactions: [],
              };
          }
          totalInserted += result.inserted;
          totalSkipped += result.skipped;
          allIds.push(...result.insertedIds);
        }
      }

      return {
        inserted: totalInserted,
        skipped: totalSkipped,
        insertedIds: allIds,
        changedTransactions,
        insertedTransactions,
      };
    },
  };
}

export async function registerWebSocketRoute(app: FastifyInstance): Promise<void> {
  await app.register(websocketPlugin);

  app.get(
    '/ws',
    {
      websocket: true,
      preValidation: async (_req, reply) => {
        if (isLocked()) {
          await reply.status(401).send({ error: 'vault-locked' });
        }
      },
    },
    (socket: WebSocket, _req) => {
      socket.on('error', err => app.log.error({ err }, '[ws] socket error'));
      send(socket, { type: 'connected' });

      const emit = (msg: ServerMessage) => send(socket, msg);
      let activeOtpManager: OtpManager | null = null;

      socket.on('message', (raw: RawData) => {
        let parsed: unknown;
        try {
          parsed = JSON.parse(raw.toString());
        } catch {
          app.log.warn('[ws] non-JSON message received');
          return;
        }

        const result = ClientMessageSchema.safeParse(parsed);
        if (!result.success) {
          app.log.warn({ received: parsed }, '[ws] unknown message type');
          send(socket, { type: 'error', message: 'Unknown message type' });
          return;
        }

        const msg = result.data;
        switch (msg.type) {
          case 'ping':
            send(socket, { type: 'pong' });
            break;
          case 'run-start': {
            let vault: Vault;
            try {
              vault = getVault();
            } catch (e) {
              send(socket, { type: 'error', message: String(e) });
              break;
            }

            const allRefs = collectSourceRefs(vault);
            const sourceIds = msg.sourceIds;
            const sources = sourceIds ? allRefs.filter(s => sourceIds.includes(s.id)) : allRefs;

            if (sources.length === 0 && !vault.settings.fetchBankOfIsraelRates) {
              send(socket, { type: 'run-error', message: 'No matching sources found' });
              break;
            }

            if (activeOtpManager) {
              send(socket, { type: 'run-error', message: ERR_RUN_IN_PROGRESS });
              break;
            }

            // Resolve date range: message overrides settings default
            const runDateTo = msg.dateTo ? new Date(msg.dateTo) : new Date();
            const months = vault.settings.defaultDateRangeMonths ?? 3;
            const runDateFrom = msg.dateFrom
              ? new Date(msg.dateFrom)
              : new Date(
                  runDateTo.getFullYear(),
                  runDateTo.getMonth() - months,
                  runDateTo.getDate(),
                );

            activeOtpManager = new OtpManager();
            const uploadClient =
              vault.settings.serverUrl && vault.settings.apiKey
                ? createUploadClient(vault.settings.serverUrl, vault.settings.apiKey)
                : null;
            const tasks: ScrapeTask[] = sources.map(src =>
              buildTask(src, vault, emit, activeOtpManager!, uploadClient, runDateFrom, runDateTo),
            );

            // Append currency-rates task if enabled
            if (vault.settings.fetchBankOfIsraelRates) {
              tasks.push({
                sourceId: 'currency-rates',
                nickname: 'Currency Rates (Bank of Israel)',
                type: 'currency-rates',
                run: async () => {
                  const payload = await scrapeCurrencyRates(emit, runDateFrom, runDateTo);
                  if (!uploadClient)
                    return {
                      inserted: 0,
                      skipped: 0,
                      insertedIds: [],
                      changedTransactions: [],
                      insertedTransactions: [],
                    };
                  emit({
                    type: 'task-month-uploading',
                    sourceId: 'currency-rates',
                    month: 'rates',
                    transactionCount: payload.length,
                  });
                  const result = await uploadClient.uploadCurrencyRates(payload);
                  emit({
                    type: 'task-month-uploaded',
                    sourceId: 'currency-rates',
                    month: 'rates',
                    inserted: result.inserted,
                    skipped: result.skipped,
                  });
                  return result;
                },
              });
            }

            const { saveHistory, historyFilePath } = vault.settings;
            void startRun(tasks, vault.settings.concurrentScraping, emit)
              .then(async runRecord => {
                if (saveHistory) {
                  try {
                    await appendRun(
                      {
                        ...runRecord,
                        startedAt: runRecord.startedAt.toISOString(),
                        completedAt: runRecord.completedAt.toISOString(),
                      },
                      historyFilePath,
                    );
                  } catch (err) {
                    app.log.error(err, '[ws] failed to append run history');
                  }
                }
              })
              .catch((err: unknown) => {
                const message = err instanceof Error ? err.message : String(err);
                if (message === ERR_RUN_IN_PROGRESS) {
                  send(socket, { type: 'run-error', message });
                } else {
                  app.log.error(err, '[ws] startRun error');
                  send(socket, { type: 'error', message });
                }
              })
              .finally(() => {
                activeOtpManager = null;
              });
            break;
          }
          case 'cancel-scrape':
            // TODO: cancel in-progress scrape
            break;
          case 'otp-submit':
            activeOtpManager?.submitOtp(msg.sourceId, msg.otp);
            app.log.info({ sourceId: msg.sourceId }, '[ws] otp-submit received');
            break;
        }
      });

      socket.on('close', () => {
        app.log.info('[ws] client disconnected');
      });
    },
  );
}
