import {
  forwardRef,
  memo,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import type { ReactNode, RefObject } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useLocation, useParams, useSearchParams } from 'react-router';

import { Button } from '@actual-app/components/button';
import { SvgHash, SvgSplit } from '@actual-app/components/icons/v0';
import {
  SvgAdd,
  SvgCalendar,
  SvgCheveronDown,
  SvgLocation,
  SvgPiggyBank,
  SvgTag,
  SvgTrash,
  SvgUser,
  SvgWallet,
} from '@actual-app/components/icons/v1';
import {
  SvgNotesPaper,
  SvgPencilWriteAlternate,
} from '@actual-app/components/icons/v2';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { Toggle } from '@actual-app/components/toggle';
import { View } from '@actual-app/components/view';
import { send } from '@actual-app/core/platform/client/connection';
import { DEFAULT_MAX_DISTANCE_METERS } from '@actual-app/core/shared/constants';
import { calculateDistance } from '@actual-app/core/shared/location-utils';
import * as monthUtils from '@actual-app/core/shared/months';
import { q } from '@actual-app/core/shared/query';
import { getUpcomingDays } from '@actual-app/core/shared/schedules';
import {
  addSplitTransaction,
  deleteTransaction,
  makeEmptySplitSubtransactions,
  realizeTempTransactions,
  splitTransaction,
  ungroupTransactions,
  updateTransaction,
} from '@actual-app/core/shared/transactions';
import {
  amountToInteger,
  applyFindReplace,
  diffItems,
  getChangedValues,
  groupById,
  integerToAmount,
  integerToCurrency,
  titleFirst,
} from '@actual-app/core/shared/util';
import type {
  AccountEntity,
  CategoryEntity,
  PayeeEntity,
  TransactionEntity,
} from '@actual-app/core/types/models';
import { css } from '@emotion/css';
import {
  format as formatDate,
  isValid as isValidDate,
  parse as parseDate,
  parseISO,
} from 'date-fns';

import { MobileBackButton } from '#components/mobile/MobileBackButton';
import {
  FieldLabel,
  InputField,
  TapField,
  ToggleField,
} from '#components/mobile/MobileForms';
import { getPrettyPayee } from '#components/mobile/utils';
import { MobilePageHeader, Page } from '#components/Page';
import { shouldApplyRuleChange } from '#components/transactions/table/utils';
import { createSingleTimeScheduleFromTransaction } from '#components/transactions/TransactionList';
import { useAccounts } from '#hooks/useAccounts';
import { useCategories } from '#hooks/useCategories';
import { useCurrentWordRange } from '#hooks/useCurrentWordRange';
import { useCursorPosition } from '#hooks/useCursorPosition';
import { useDateFormat } from '#hooks/useDateFormat';
import { useInputRefValue } from '#hooks/useInputRefValue';
import { useLocalPref } from '#hooks/useLocalPref';
import { useLocationPermission } from '#hooks/useLocationPermission';
import { useNavigate } from '#hooks/useNavigate';
import { useNearbyPayees } from '#hooks/useNearbyPayees';
import { usePayees } from '#hooks/usePayees';
import {
  SingleActiveEditFormProvider,
  useSingleActiveEditForm,
} from '#hooks/useSingleActiveEditForm';
import { useSyncedPref } from '#hooks/useSyncedPref';
import { useTagCSS } from '#hooks/useTagCSS';
import { useFilteredTags } from '#hooks/useTags';
import { pushModal } from '#modals/modalsSlice';
import { addNotification } from '#notifications/notificationsSlice';
import { useSavePayeeLocationMutation } from '#payees';
import { locationService } from '#payees/location';
import { aqlQuery } from '#queries/aqlQuery';
import { useDispatch, useSelector } from '#redux';
import { setLastTransaction } from '#transactions/transactionsSlice';
import { getStatusLabel } from '#util/schedule';

import { AmountInput } from './AmountInput';
import { SplitAmountInput } from './SplitAmountInput';

function getFieldName(transactionId: TransactionEntity['id'], field: string) {
  return `${field}-${transactionId}`;
}

function serializeTransaction(
  transaction: TransactionEntity,
  dateFormat: string,
) {
  const { date, amount } = transaction;
  return {
    ...transaction,
    date: formatDate(parseISO(date), dateFormat),
    amount: integerToAmount(amount || 0),
  };
}

function deserializeTransaction(
  transaction: TransactionEntity,
  originalTransaction: TransactionEntity | null,
  dateFormat: string,
) {
  const { amount, date: originalDate, ...realTransaction } = transaction;

  const dayMonth = monthUtils.getDayMonthRegex(dateFormat);
  let date: string | null = originalDate;
  if (dayMonth.test(date)) {
    const test = parseDate(
      date,
      monthUtils.getDayMonthFormat(dateFormat),
      new Date(),
    );
    if (isValidDate(test)) {
      date = monthUtils.dayFromDate(test);
    } else {
      date = null;
    }
  } else {
    const test = parseDate(date, dateFormat, new Date());
    // This is a quick sanity check to make sure something invalid
    // like "year 201" was entered
    if (test.getFullYear() > 2000 && isValidDate(test)) {
      date = monthUtils.dayFromDate(test);
    } else {
      date = null;
    }
  }

  if (date == null) {
    date =
      (originalTransaction && originalTransaction.date) ||
      monthUtils.currentDay();
  }

  return { ...realTransaction, date, amount: amountToInteger(amount || 0) };
}

export function lookupName(items: CategoryEntity[], id?: CategoryEntity['id']) {
  if (!id) {
    return null;
  }
  return items.find(item => item.id === id)?.name;
}

const dropdownChevron = (
  <SvgCheveronDown
    width={14}
    height={14}
    style={{ color: theme.pageTextSubdued, marginRight: 8 }}
  />
);

export function Status({
  status,
  isSplit = false,
}: {
  status?: string;
  isSplit?: boolean;
}) {
  const { t } = useTranslation();

  let color: string | undefined;

  switch (status) {
    case 'missed':
      color = theme.errorText;
      break;
    case 'due':
      color = theme.warningText;
      break;
    case 'upcoming':
      color = theme.tableHeaderText;
      break;
    default:
      return null;
  }

  return (
    <Text
      style={{
        fontSize: 11,
        color,
        fontStyle: 'italic',
        textAlign: 'left',
      }}
    >
      {isSplit
        ? t('{{status}} (Split)', {
            status: titleFirst(getStatusLabel(status)),
          })
        : titleFirst(getStatusLabel(status))}
    </Text>
  );
}

type FooterProps = {
  transactions: TransactionEntity[];
  isAdding: boolean;
  onAdd: () => void;
  onSave: () => void;
  onSplit: (id: TransactionEntity['id']) => void;
  onAddSplit: (id: TransactionEntity['id']) => void;
  onEmptySplitFound: (id: TransactionEntity['id']) => void;
  editingField?: string;
  onEditField: (
    id: TransactionEntity['id'],
    field: 'category' | 'payee' | 'account' | 'date' | 'amount' | 'notes',
  ) => void;
};

function Footer({
  transactions,
  isAdding,
  onAdd,
  onSave,
  onSplit,
  onAddSplit,
  onEmptySplitFound,
  editingField,
  onEditField,
}: FooterProps) {
  const [transaction, ...childTransactions] = transactions;
  const emptySplitTransaction = childTransactions.find(t => t.amount === 0);
  const onClickRemainingSplit = () => {
    if (childTransactions.length === 0) {
      onSplit(transaction.id);
    } else {
      if (!emptySplitTransaction) {
        onAddSplit(transaction.id);
      } else {
        onEmptySplitFound?.(emptySplitTransaction.id);
      }
    }
  };

  return (
    <View
      data-testid="transaction-form-footer"
      style={{
        paddingLeft: styles.mobileEditingPadding,
        paddingRight: styles.mobileEditingPadding,
        paddingTop: 10,
        paddingBottom: 'calc(10px + env(safe-area-inset-bottom))',
        backgroundColor: theme.tableHeaderBackground,
        borderTopWidth: 1,
        borderColor: theme.tableBorder,
      }}
    >
      {transaction.error?.type === 'SplitTransactionError' ? (
        <Button
          variant="primary"
          style={{ height: styles.mobileMinHeight }}
          isDisabled={!!editingField}
          onPress={onClickRemainingSplit}
        >
          <SvgSplit width={17} height={17} />
          <Text
            style={{
              ...styles.text,
              marginLeft: 6,
            }}
          >
            {!emptySplitTransaction ? (
              <Trans>
                Add new split -{' '}
                {{
                  amount: integerToCurrency(
                    transaction.amount > 0
                      ? transaction.error.difference
                      : -transaction.error.difference,
                  ),
                }}{' '}
                left
              </Trans>
            ) : (
              <Trans>
                Amount left:{' '}
                {{
                  amount: integerToCurrency(
                    transaction.amount > 0
                      ? transaction.error.difference
                      : -transaction.error.difference,
                  ),
                }}
              </Trans>
            )}
          </Text>
        </Button>
      ) : !transaction.account ? (
        <Button
          variant="primary"
          style={{ height: styles.mobileMinHeight }}
          isDisabled={!!editingField}
          onPress={() => onEditField(transaction.id, 'account')}
        >
          <SvgPiggyBank width={17} height={17} />
          <Text
            style={{
              ...styles.text,
              marginLeft: 6,
            }}
          >
            <Trans>Select account</Trans>
          </Text>
        </Button>
      ) : isAdding ? (
        <Button
          variant="primary"
          style={{ height: styles.mobileMinHeight }}
          isDisabled={!!editingField}
          onPress={onAdd}
        >
          <SvgAdd width={17} height={17} />
          <Text
            style={{
              ...styles.text,
              marginLeft: 5,
            }}
          >
            <Trans>Add transaction</Trans>
          </Text>
        </Button>
      ) : (
        <Button
          variant="primary"
          style={{ height: styles.mobileMinHeight }}
          isDisabled={!!editingField}
          onPress={onSave}
        >
          <SvgPencilWriteAlternate width={16} height={16} />
          <Text
            style={{
              ...styles.text,
              marginLeft: 6,
            }}
          >
            <Trans>Save changes</Trans>
          </Text>
        </Button>
      )}
    </View>
  );
}

type ChildTransactionEditProps = {
  keyboardHeader: ReactNode;
  transaction: TransactionEntity;
  negate: boolean;
  amountFocused: boolean;
  getCategory: (transaction: TransactionEntity, isOffBudget: boolean) => string;
  getPayee: (transaction: TransactionEntity) => PayeeEntity | undefined;
  getTransferAccount: (
    transaction: TransactionEntity,
  ) => AccountEntity | undefined;
  isOffBudget: boolean;
  isBudgetTransfer: (transaction: TransactionEntity) => boolean;
  onEditField: (
    id: TransactionEntity['id'],
    field: 'category' | 'payee' | 'account' | 'date' | 'amount' | 'notes',
  ) => void;
  onUpdate: <Field extends keyof TransactionEntity>(
    transaction: TransactionEntity,
    field: Field,
    value: TransactionEntity[Field],
  ) => void;
  onDelete: (id: TransactionEntity['id']) => void;
};

const ChildTransactionEdit = forwardRef<
  HTMLDivElement,
  ChildTransactionEditProps
>(
  (
    {
      transaction,
      negate,
      amountFocused,
      getCategory,
      getPayee,
      getTransferAccount,
      isOffBudget,
      isBudgetTransfer,
      onEditField,
      onUpdate,
      onDelete,
      keyboardHeader,
    },
    ref,
  ) => {
    const { t } = useTranslation();
    const { editingField, onRequestActiveEdit, onClearActiveEdit } =
      useSingleActiveEditForm()!;
    const noteRef = useRef<HTMLInputElement | null>(null);

    const prettyPayee = getPrettyPayee({
      t,
      transaction,
      payee: getPayee(transaction),
      transferAccount: getTransferAccount(transaction),
    });
    return (
      <View
        innerRef={ref}
        style={{
          backgroundColor: theme.tableBackground,
          borderColor:
            transaction.amount === 0
              ? theme.tableBorderSelected
              : theme.tableBorder,
          borderWidth: '1px',
          borderRadius: '5px',
          padding: '5px',
          margin: '10px',
        }}
      >
        <View style={{ flexDirection: 'row' }}>
          <View style={{ flexBasis: '75%' }}>
            <FieldLabel title={t('Payee')} />
            <TapField
              icon={<SvgUser width={17} height={17} />}
              placeholder={t('Who did you pay?')}
              rightContent={dropdownChevron}
              isDisabled={
                !!editingField &&
                editingField !== getFieldName(transaction.id, 'payee')
              }
              value={prettyPayee}
              onPress={() => onEditField(transaction.id, 'payee')}
              data-testid={`payee-field-${transaction.id}`}
            />
          </View>
          <View
            style={{
              flexBasis: '25%',
            }}
          >
            <FieldLabel title={t('Amount')} style={{ padding: 0 }} />
            <SplitAmountInput
              keyboardHeader={keyboardHeader}
              disabled={
                !!editingField &&
                editingField !== getFieldName(transaction.id, 'amount')
              }
              value={transaction.amount}
              negate={negate}
              autoFocus={amountFocused}
              onFocus={event => {
                onRequestActiveEdit(getFieldName(transaction.id, 'amount'));

                const input = event.currentTarget as HTMLInputElement;
                input.scrollIntoView({ behavior: 'smooth', block: 'start' });
              }}
              onBlur={() => onClearActiveEdit()}
              onChange={amount => {
                onUpdate(transaction, 'amount', amount);
              }}
            />
          </View>
        </View>

        <View>
          <FieldLabel title={t('Category')} />
          <TapField
            icon={<SvgTag width={17} height={17} />}
            placeholder={t('Select a category')}
            rightContent={dropdownChevron}
            textStyle={{
              ...((isOffBudget || isBudgetTransfer(transaction)) && {
                fontStyle: 'italic',
                color: theme.pageTextSubdued,
                fontWeight: 300,
              }),
            }}
            value={getCategory(transaction, isOffBudget)}
            isDisabled={
              (!!editingField &&
                editingField !== getFieldName(transaction.id, 'category')) ||
              isOffBudget ||
              isBudgetTransfer(transaction)
            }
            onPress={() => onEditField(transaction.id, 'category')}
            data-testid={`category-field-${transaction.id}`}
          />
        </View>

        <View>
          <FieldLabel title={t('Notes')} />
          <InputField
            ref={noteRef}
            iconStart={<SvgNotesPaper width={17} height={17} />}
            iconEnd={<NoteInsertHashButton noteRef={noteRef} />}
            placeholder={t('Add a note (optional)')}
            disabled={
              !!editingField &&
              editingField !== getFieldName(transaction.id, 'notes')
            }
            defaultValue={transaction.notes}
            onFocus={() =>
              onRequestActiveEdit(getFieldName(transaction.id, 'notes'))
            }
            onUpdate={value => onUpdate(transaction, 'notes', value)}
          />
          <NoteTagAutocomplete inputRef={noteRef} />
        </View>

        <View style={{ alignItems: 'center' }}>
          <Button
            variant="bare"
            onPress={() => onDelete(transaction.id)}
            style={{
              height: 40,
              borderWidth: 0,
              marginLeft: styles.mobileEditingPadding,
              marginRight: styles.mobileEditingPadding,
              marginTop: 10,
              backgroundColor: 'transparent',
            }}
          >
            <SvgTrash
              width={17}
              height={17}
              style={{ color: theme.errorText }}
            />
            <Text
              style={{
                color: theme.errorText,
                marginLeft: 5,
                userSelect: 'none',
              }}
            >
              <Trans>Delete split</Trans>
            </Text>
          </Button>
        </View>
      </View>
    );
  },
);

ChildTransactionEdit.displayName = 'ChildTransactionEdit';

type TransactionEditInnerProps = {
  isAdding: boolean;
  accounts: AccountEntity[];
  categories: CategoryEntity[];
  payees: PayeeEntity[];
  dateFormat: string;
  transactions: TransactionEntity[];
  onSave: (transactions: TransactionEntity[]) => void;
  onUpdate: <Field extends keyof TransactionEntity>(
    transaction: TransactionEntity,
    field: Field,
    value?: TransactionEntity[Field],
  ) => void;
  onDelete: (id: TransactionEntity['id']) => void;
  onSplit: (id: TransactionEntity['id']) => void;
  onAddSplit: (id: TransactionEntity['id']) => void;
  shouldShowSaveLocation?: boolean;
  onSaveLocation?: () => void;
  onSelectNearestPayee?: () => void;
  onRequestLocation?: () => void;
  nearestPayee?: PayeeEntity | null;
};

const TransactionEditInner = memo<TransactionEditInnerProps>(
  function TransactionEditInner({
    isAdding,
    accounts,
    categories,
    payees,
    dateFormat,
    transactions: unserializedTransactions,
    onSave,
    onUpdate,
    onDelete,
    onSplit,
    onAddSplit,
    shouldShowSaveLocation,
    onSaveLocation,
    onSelectNearestPayee,
    onRequestLocation,
    nearestPayee,
  }) {
    const { t } = useTranslation();
    const navigate = useNavigate();
    const dispatch = useDispatch();
    const [showHiddenCategories] = useLocalPref('budget.showHiddenCategories');
    const [upcomingLength = '7'] = useSyncedPref(
      'upcomingScheduledTransactionLength',
    );
    const transactions = useMemo(
      () =>
        unserializedTransactions.map(t =>
          serializeTransaction(t, dateFormat),
        ) || [],
      [unserializedTransactions, dateFormat],
    );
    const { data: { grouped: categoryGroups } = { grouped: [] } } =
      useCategories();
    const noteRef = useRef<HTMLInputElement | null>(null);

    useEffect(() => {
      if (window.history.length === 1) {
        window.history.replaceState(null, 'Actual Budget', '/');
        window.history.pushState(null, 'Add Transaction', '/transactions/new');
      }
    }, []);

    const [transaction, ...childTransactions] = transactions;

    const { editingField, onRequestActiveEdit, onClearActiveEdit } =
      useSingleActiveEditForm()!;
    const childTransactionElementRefMap = useRef<
      Record<TransactionEntity['id'], HTMLDivElement | null>
    >({});
    const hasAccountChanged = useRef(false);

    const payeesById = useMemo(() => groupById(payees), [payees]);
    const accountsById = useMemo(() => groupById(accounts), [accounts]);

    const getAccount = useCallback(
      (trans: TransactionEntity) => {
        return trans?.account ? accountsById?.[trans.account] : undefined;
      },
      [accountsById],
    );

    const getPayee = useCallback(
      (trans: TransactionEntity) => {
        return trans?.payee ? payeesById?.[trans.payee] : undefined;
      },
      [payeesById],
    );

    const getTransferAccount = useCallback(
      (trans: TransactionEntity) => {
        const payee = trans ? getPayee(trans) : null;
        return payee && payee?.transfer_acct
          ? accountsById?.[payee.transfer_acct]
          : undefined;
      },
      [accountsById, getPayee],
    );

    const isBudgetTransfer = useCallback(
      (trans: TransactionEntity) => {
        const transferAcct = trans ? getTransferAccount(trans) : null;
        return transferAcct ? !transferAcct.offbudget : false;
      },
      [getTransferAccount],
    );

    const getCategory = useCallback(
      (trans: TransactionEntity, isOffBudget: boolean) => {
        if (isOffBudget) {
          return t('Off budget');
        } else if (isBudgetTransfer(trans)) {
          return t('Transfer');
        } else {
          return lookupName(categories, trans.category) ?? '';
        }
      },
      [categories, isBudgetTransfer, t],
    );

    const onSaveInner = useCallback(async () => {
      const [unserializedTransaction] = unserializedTransactions;

      const onConfirmSave = () => {
        let transactionsToSave = unserializedTransactions;
        if (isAdding) {
          transactionsToSave = realizeTempTransactions(
            unserializedTransactions,
          );
        }

        onSave(transactionsToSave);
        void navigate(-1);
      };

      const today = monthUtils.currentDay();
      const isFuture = unserializedTransaction.date > today;
      const isLinkedToSchedule = !!unserializedTransaction.schedule;

      if (isFuture && !isLinkedToSchedule) {
        const upcomingDays = getUpcomingDays(upcomingLength, today);
        const daysUntilTransaction = monthUtils.differenceInCalendarDays(
          unserializedTransaction.date,
          today,
        );
        const isBeyondWindow = daysUntilTransaction > upcomingDays;

        dispatch(
          pushModal({
            modal: {
              name: 'convert-to-schedule',
              options: {
                isBeyondWindow,
                daysUntilTransaction,
                upcomingDays,
                onConfirm: async () => {
                  if (
                    !isAdding &&
                    unserializedTransaction.id &&
                    !unserializedTransaction.id.startsWith('temp')
                  ) {
                    await send('transaction-delete', {
                      id: unserializedTransaction.id,
                    });
                  }

                  const transactionForSchedule =
                    unserializedTransaction.is_parent
                      ? {
                          ...unserializedTransaction,
                          subtransactions: unserializedTransactions.filter(
                            t =>
                              t.is_child &&
                              t.parent_id === unserializedTransaction.id,
                          ),
                        }
                      : unserializedTransaction;

                  await createSingleTimeScheduleFromTransaction(
                    transactionForSchedule,
                  );

                  dispatch(
                    addNotification({
                      notification: {
                        type: 'message',
                        message: t('Schedule created successfully'),
                      },
                    }),
                  );
                  void navigate(-1);
                },
                onCancel: onConfirmSave,
              },
            },
          }),
        );
        return;
      }

      if (unserializedTransactions.some(t => t.reconciled)) {
        // On mobile any save gives the warning.
        // On the web only certain changes trigger a warning.
        // Should we bring that here as well? Or does the nature of the editing form
        // make this more appropriate?
        dispatch(
          pushModal({
            modal: {
              name: 'confirm-transaction-edit',
              options: {
                onConfirm: onConfirmSave,
                confirmReason: 'editReconciled',
              },
            },
          }),
        );
      } else {
        const transferIds = unserializedTransactions
          .map(t => t.transfer_id)
          .filter((id): id is string => id != null);

        if (transferIds.length > 0) {
          const { data } = await aqlQuery(
            q('transactions')
              .filter({
                id: { $oneof: transferIds },
                reconciled: true,
              })
              .select('id'),
          );
          if ((data as TransactionEntity[]).length > 0) {
            dispatch(
              pushModal({
                modal: {
                  name: 'confirm-transaction-edit',
                  options: {
                    onConfirm: onConfirmSave,
                    confirmReason: 'batchEditWithReconciledTransfer',
                  },
                },
              }),
            );
          } else {
            onConfirmSave();
          }
        } else {
          onConfirmSave();
        }
      }
    }, [
      isAdding,
      dispatch,
      navigate,
      onSave,
      unserializedTransactions,
      upcomingLength,
      t,
    ]);

    const onUpdateInner = useCallback(
      async <Field extends keyof TransactionEntity>(
        serializedTransaction: TransactionEntity,
        name: Field,
        value: TransactionEntity[Field],
      ) => {
        const newTransaction = { ...serializedTransaction, [name]: value };
        onUpdate(newTransaction, name);
        onClearActiveEdit();

        if (name === 'account') {
          hasAccountChanged.current = serializedTransaction.account !== value;
        }
      },
      [onClearActiveEdit, onUpdate],
    );

    const onTotalAmountUpdate = useCallback(
      (value: number) => {
        if (transaction.amount !== value) {
          void onUpdateInner(transaction, 'amount', value);
        }
      },
      [onUpdateInner, transaction],
    );

    const onEditFieldInner = useCallback(
      (
        transactionId: TransactionEntity['id'],
        name: 'category' | 'payee' | 'account' | 'date' | 'amount' | 'notes',
      ) => {
        onRequestActiveEdit?.(getFieldName(transaction.id, name), () => {
          const transactionToEdit = transactions.find(
            t => t.id === transactionId,
          );
          const unserializedTransaction = unserializedTransactions.find(
            t => t.id === transactionId,
          );

          if (!unserializedTransaction || !transactionToEdit) {
            throw new Error(`Transaction ${transactionId} not found`);
          }

          switch (name) {
            case 'category':
              dispatch(
                pushModal({
                  modal: {
                    name: 'category-autocomplete',
                    options: {
                      categoryGroups,
                      showHiddenCategories,
                      showNoneOption: true,
                      month: monthUtils.monthFromDate(
                        unserializedTransaction.date,
                      ),
                      onSelect: categoryId => {
                        void onUpdateInner(
                          transactionToEdit,
                          name,
                          categoryId as TransactionEntity['category'],
                        );
                      },
                      onClose: () => {
                        onClearActiveEdit();
                      },
                    },
                  },
                }),
              );
              break;
            case 'account':
              dispatch(
                pushModal({
                  modal: {
                    name: 'account-autocomplete',
                    options: {
                      onSelect: accountId => {
                        void onUpdateInner(transactionToEdit, name, accountId);
                      },
                      onClose: () => {
                        onClearActiveEdit();
                      },
                    },
                  },
                }),
              );
              break;
            case 'payee':
              dispatch(
                pushModal({
                  modal: {
                    name: 'payee-autocomplete',
                    options: {
                      onSelect: payeeId => {
                        void onUpdateInner(transactionToEdit, name, payeeId);
                      },
                      onClose: () => {
                        onClearActiveEdit();
                      },
                    },
                  },
                }),
              );
              break;
            default:
              dispatch(
                pushModal({
                  modal: {
                    name: 'edit-field',
                    options: {
                      name,
                      onSubmit: (name, value) => {
                        if (typeof value === 'object' && 'useRegex' in value) {
                          void onUpdateInner(
                            transactionToEdit,
                            name,
                            applyFindReplace(
                              transactionToEdit.notes,
                              value.find,
                              value.replace,
                              value.useRegex,
                            ),
                          );
                        } else {
                          void onUpdateInner(transactionToEdit, name, value);
                        }
                      },
                      onClose: () => {
                        onClearActiveEdit();
                      },
                    },
                  },
                }),
              );
              break;
          }
        });
      },
      [
        categoryGroups,
        dispatch,
        onUpdateInner,
        onClearActiveEdit,
        onRequestActiveEdit,
        transaction.id,
        transactions,
        unserializedTransactions,
        showHiddenCategories,
      ],
    );

    const onDeleteInner = useCallback(
      async (id: TransactionEntity['id']) => {
        const [parentTransaction] = unserializedTransactions;
        const targetTransaction =
          unserializedTransactions.find(t => t.id === id) ?? parentTransaction;

        const onConfirmDelete = () => {
          dispatch(
            pushModal({
              modal: {
                name: 'confirm-delete',
                options: {
                  message: t(
                    'Are you sure you want to delete the transaction?',
                  ),
                  onConfirm: () => {
                    onDelete(id);

                    if (parentTransaction.id !== id) {
                      // Only a child transaction was deleted.
                      onClearActiveEdit();
                      return;
                    }

                    void navigate(-1);
                  },
                },
              },
            }),
          );
        };

        if (targetTransaction.reconciled) {
          dispatch(
            pushModal({
              modal: {
                name: 'confirm-transaction-edit',
                options: {
                  onConfirm: onConfirmDelete,
                  confirmReason: 'deleteReconciled',
                },
              },
            }),
          );
        } else if (targetTransaction.transfer_id) {
          const { data } = await aqlQuery(
            q('transactions')
              .filter({
                id: targetTransaction.transfer_id,
                reconciled: true,
              })
              .select('id'),
          );
          if ((data as TransactionEntity[]).length > 0) {
            dispatch(
              pushModal({
                modal: {
                  name: 'confirm-transaction-edit',
                  options: {
                    onConfirm: onConfirmDelete,
                    confirmReason: 'batchDeleteWithReconciledTransfer',
                  },
                },
              }),
            );
          } else {
            onConfirmDelete();
          }
        } else {
          onConfirmDelete();
        }
      },
      [
        dispatch,
        navigate,
        onClearActiveEdit,
        onDelete,
        unserializedTransactions,
        t,
      ],
    );

    const scrollChildTransactionIntoView = useCallback(
      (id: TransactionEntity['id']) => {
        const childTransactionEditElement =
          childTransactionElementRefMap.current?.[id];
        childTransactionEditElement?.scrollIntoView({
          behavior: 'smooth',
        });
      },
      [],
    );

    const onEmptySplitFound = useCallback(
      (id: TransactionEntity['id']) => {
        scrollChildTransactionIntoView(id);
      },
      [scrollChildTransactionIntoView],
    );

    const account = getAccount(transaction);
    const isOffBudget = account ? !!account.offbudget : false;
    const title = getPrettyPayee({
      t,
      transaction,
      payee: getPayee(transaction),
      transferAccount: getTransferAccount(transaction),
    });

    const transactionDate = parseDate(transaction.date, dateFormat, new Date());
    const dateDefaultValue = monthUtils.dayFromDate(transactionDate);

    const remaining = transaction.error?.difference ?? 0;

    return (
      <Page
        header={
          <MobilePageHeader
            title={
              transaction.payee == null
                ? isAdding
                  ? t('New Transaction')
                  : t('Transaction')
                : title
            }
            leftContent={<MobileBackButton />}
          />
        }
        footer={
          <Footer
            transactions={transactions}
            isAdding={isAdding}
            onAdd={onSaveInner}
            onSave={onSaveInner}
            onSplit={onSplit}
            onAddSplit={onAddSplit}
            onEmptySplitFound={onEmptySplitFound}
            editingField={editingField}
            onEditField={onEditFieldInner}
          />
        }
        padding={0}
      >
        <View
          data-testid="transaction-form"
          style={{ flexShrink: 0, marginTop: 20, marginBottom: 20 }}
        >
          <View
            style={{
              alignItems: 'center',
            }}
          >
            <FieldLabel title={t('Amount')} flush style={{ marginBottom: 0 }} />
            <AmountInput
              value={transaction.amount}
              negate
              disabled={
                !!editingField &&
                editingField !== getFieldName(transaction.id, 'amount')
              }
              autoFocus={isAdding}
              disableNativeAutoFocusOnIOS
              onFocus={() => {
                onRequestActiveEdit(getFieldName(transaction.id, 'amount'));
              }}
              onBlur={() => onClearActiveEdit()}
              onChange={onTotalAmountUpdate}
              variant="large"
            />
          </View>

          <View>
            <FieldLabel title={t('Payee')} />
            <TapField
              icon={<SvgUser width={17} height={17} />}
              placeholder={t('Who did you pay?')}
              textStyle={{
                ...(transaction.is_parent && {
                  fontStyle: 'italic',
                  fontWeight: 300,
                }),
              }}
              value={title}
              isDisabled={
                !!editingField &&
                editingField !== getFieldName(transaction.id, 'payee')
              }
              onPress={() => onEditFieldInner(transaction.id, 'payee')}
              data-testid="payee-field"
              alwaysShowRightContent={
                (!!nearestPayee || !!onRequestLocation) &&
                !transaction.payee &&
                !shouldShowSaveLocation
              }
              rightContent={
                shouldShowSaveLocation ? (
                  <Button
                    variant="bare"
                    onPress={onSaveLocation}
                    style={{
                      backgroundColor: theme.buttonNormalBackground,
                      border: `1px solid ${theme.buttonNormalBorder}`,
                      color: theme.buttonNormalText,
                      fontSize: '11px',
                      padding: '4px 8px',
                      borderRadius: 3,
                      height: 'auto',
                      minHeight: 'auto',
                    }}
                  >
                    <Trans>Save</Trans>
                    <SvgLocation
                      width={10}
                      height={10}
                      style={{ marginLeft: 4 }}
                    />
                  </Button>
                ) : nearestPayee && !transaction.payee ? (
                  <Button
                    variant="bare"
                    onPress={onSelectNearestPayee}
                    style={{
                      backgroundColor: theme.buttonNormalBackground,
                      border: `1px solid ${theme.buttonNormalBorder}`,
                      color: theme.buttonNormalText,
                      fontSize: '11px',
                      padding: '4px 8px',
                      borderRadius: 3,
                      height: 'auto',
                      minHeight: 'auto',
                    }}
                  >
                    <Trans>Nearby</Trans>
                    <SvgLocation
                      width={10}
                      height={10}
                      style={{ marginLeft: 4 }}
                    />
                  </Button>
                ) : onRequestLocation && !transaction.payee ? (
                  <Button
                    variant="bare"
                    onPress={onRequestLocation}
                    style={{
                      backgroundColor: theme.buttonNormalBackground,
                      border: `1px solid ${theme.buttonNormalBorder}`,
                      color: theme.buttonNormalText,
                      fontSize: '11px',
                      padding: '4px 8px',
                      borderRadius: 3,
                      height: 'auto',
                      minHeight: 'auto',
                    }}
                  >
                    <Trans>Request Location</Trans>
                    <SvgLocation
                      width={10}
                      height={10}
                      style={{ marginLeft: 4 }}
                    />
                  </Button>
                ) : (
                  dropdownChevron
                )
              }
            />
          </View>

          {!transaction.is_parent && (
            <View>
              <FieldLabel title={t('Category')} />
              <TapField
                icon={<SvgTag width={17} height={17} />}
                placeholder={t('Select a category')}
                rightContent={dropdownChevron}
                style={{
                  ...((isOffBudget || isBudgetTransfer(transaction)) && {
                    fontStyle: 'italic',
                    color: theme.pageTextSubdued,
                    fontWeight: 300,
                  }),
                }}
                value={getCategory(transaction, isOffBudget)}
                isDisabled={
                  (!!editingField &&
                    editingField !==
                      getFieldName(transaction.id, 'category')) ||
                  isOffBudget ||
                  isBudgetTransfer(transaction)
                }
                onPress={() => onEditFieldInner(transaction.id, 'category')}
                data-testid="category-field"
              />
            </View>
          )}

          {childTransactions.map((childTrans, i, arr) => (
            <ChildTransactionEdit
              key={childTrans.id}
              transaction={childTrans}
              negate={transaction.amount <= 0}
              amountFocused={arr.findIndex(c => c.amount === 0) === i}
              ref={r => {
                childTransactionElementRefMap.current = {
                  ...childTransactionElementRefMap.current,
                  [childTrans.id]: r,
                };
              }}
              isOffBudget={isOffBudget}
              getCategory={getCategory}
              getPayee={getPayee}
              getTransferAccount={getTransferAccount}
              isBudgetTransfer={isBudgetTransfer}
              onUpdate={onUpdateInner}
              onEditField={onEditFieldInner}
              onDelete={onDeleteInner}
              keyboardHeader={
                <FillRemainingButton
                  remaining={remaining}
                  onPress={() => {
                    void onUpdateInner(
                      childTrans,
                      'amount',
                      integerToAmount(remaining),
                    );
                  }}
                />
              }
            />
          ))}

          {transaction.amount !== 0 && childTransactions.length === 0 && (
            <View style={{ alignItems: 'center' }}>
              <Button
                variant="bare"
                isDisabled={!!editingField}
                style={{
                  height: 40,
                  borderWidth: 0,
                  marginLeft: styles.mobileEditingPadding,
                  marginRight: styles.mobileEditingPadding,
                  marginTop: 10,
                  backgroundColor: 'transparent',
                }}
                onPress={() => onSplit(transaction.id)}
              >
                <SvgSplit
                  width={17}
                  height={17}
                  style={{ color: theme.formLabelText }}
                />
                <Text
                  style={{
                    marginLeft: 5,
                    userSelect: 'none',
                    color: theme.formLabelText,
                  }}
                >
                  <Trans>Split</Trans>
                </Text>
              </Button>
            </View>
          )}

          <View>
            <FieldLabel title={t('Account')} />
            <TapField
              icon={<SvgWallet width={17} height={17} />}
              placeholder={t('Select an account')}
              rightContent={dropdownChevron}
              isDisabled={
                !!editingField &&
                editingField !== getFieldName(transaction.id, 'account')
              }
              value={account?.name}
              onPress={() => onEditFieldInner(transaction.id, 'account')}
              data-testid="account-field"
            />
          </View>

          <View style={{ flexDirection: 'row' }}>
            <View style={{ flex: 1 }}>
              <FieldLabel title={t('Date')} />
              <InputField
                type="date"
                iconStart={<SvgCalendar width={17} height={17} />}
                disabled={
                  !!editingField &&
                  editingField !== getFieldName(transaction.id, 'date')
                }
                required
                style={{
                  color: theme.tableText,
                  minWidth: '150px',
                  appearance: 'none',
                }}
                defaultValue={dateDefaultValue}
                onBlur={() => onClearActiveEdit()}
                onFocus={() =>
                  onRequestActiveEdit(getFieldName(transaction.id, 'date'))
                }
                onChange={event =>
                  onUpdateInner(
                    transaction,
                    'date',
                    formatDate(parseISO(event.target.value), dateFormat),
                  )
                }
              />
            </View>
            {transaction.reconciled ? (
              <View style={{ alignItems: 'center' }}>
                <FieldLabel title={t('Reconciled')} />
                <Toggle id="Reconciled" isOn isDisabled />
              </View>
            ) : (
              <View style={{ alignItems: 'center' }}>
                <FieldLabel title={t('Cleared')} />
                <ToggleField
                  id="cleared"
                  isOn={!!transaction.cleared}
                  onToggle={on => onUpdateInner(transaction, 'cleared', on)}
                />
              </View>
            )}
          </View>

          <View>
            <FieldLabel title={t('Notes')} />
            <InputField
              ref={noteRef}
              iconStart={<SvgNotesPaper width={17} height={17} />}
              iconEnd={<NoteInsertHashButton noteRef={noteRef} />}
              placeholder={t('Add a note (optional)')}
              disabled={
                !!editingField &&
                editingField !== getFieldName(transaction.id, 'notes')
              }
              defaultValue={transaction.notes}
              onFocus={() => {
                onRequestActiveEdit(getFieldName(transaction.id, 'notes'));
              }}
              onBlur={() => onClearActiveEdit()}
              onChange={event =>
                onUpdateInner(transaction, 'notes', event.target.value)
              }
            />
            <NoteTagAutocomplete inputRef={noteRef} />
          </View>

          {!isAdding && (
            <View style={{ alignItems: 'center' }}>
              <Button
                variant="bare"
                onPress={() => onDeleteInner(transaction.id)}
                style={{
                  height: 40,
                  borderWidth: 0,
                  marginLeft: styles.mobileEditingPadding,
                  marginRight: styles.mobileEditingPadding,
                  marginTop: 10,
                  backgroundColor: 'transparent',
                }}
              >
                <SvgTrash
                  width={17}
                  height={17}
                  style={{ color: theme.errorText }}
                />
                <Text
                  style={{
                    color: theme.errorText,
                    marginLeft: 5,
                    userSelect: 'none',
                  }}
                >
                  <Trans>Delete transaction</Trans>
                </Text>
              </Button>
            </View>
          )}
        </View>
      </Page>
    );
  },
);

function NoteInsertHashButton({
  noteRef,
}: {
  noteRef: RefObject<HTMLInputElement | null>;
}) {
  const { t } = useTranslation();
  const [inputValue, setInputValue] = useInputRefValue(noteRef);
  const [_, setCursorPosition] = useCursorPosition(noteRef);

  return (
    <Button
      variant="bare"
      aria-label={t('Add tag')}
      style={{ color: 'inherit', padding: 1 }}
      onPointerDown={e => e.preventDefault()}
      onClick={() => {
        if (!noteRef.current) return;
        const isFocused = document.activeElement === noteRef.current;
        const start = isFocused
          ? (noteRef.current.selectionStart ?? 0)
          : inputValue.length;
        const end = isFocused
          ? (noteRef.current.selectionEnd ?? 0)
          : inputValue.length;

        const before = inputValue.substring(0, start);
        const after = inputValue.substring(end);

        const space = start === 0 || before.match(/\s$/) ? '' : ' ';

        setInputValue(before + space + '#' + after);
        noteRef.current.focus();
        setCursorPosition(start + 1 + space.length);
        // so Safari requires that I do noteRef.current.focus() synchronously,
        // but Chrome doesn't work unless I do it after. We do both this way.
        // If the element is already focused, these invocations have no effect
        setTimeout(() => noteRef.current?.focus(), 1);
      }}
    >
      <SvgHash width={17} height={17} />
    </Button>
  );
}

function NoteTagAutocomplete({
  inputRef,
}: {
  inputRef: RefObject<HTMLInputElement | null>;
}) {
  const dispatch = useDispatch();
  // Yes, there is a lot of ref usages in this component. Here's the motivation
  // 1. This component purely modifies HTML Input state, app state is handled elsewhere
  // 2. This component deals with cursor state, which is not easily accessible through regular React code
  // 3. Child transaction notes (transaction.notes) does not update until blur, so we have to use input state
  // 4. Given we are already using inputRef in multiple locations, I elected to simplify the props to just the ref and use HTML/JS events

  const [note, setNote] = useInputRefValue(inputRef);

  const [cursorPosition] = useCursorPosition(inputRef);
  const [startIdx, endIdx] = useCurrentWordRange(note, cursorPosition);
  const currentWord = note.slice(startIdx, endIdx);
  const currentWordNoHash = currentWord.replace(/^#+/, '');
  const { data: filteredTags, refetch } = useFilteredTags(currentWord, true);
  const showNewTag =
    currentWord.startsWith('#') &&
    currentWordNoHash &&
    !filteredTags.some(tag => tag.tag === currentWordNoHash);

  const getTagCSS = useTagCSS({ ellipsis: true });

  function handleSelect(tag: string) {
    if (!inputRef.current) return;
    const newValue =
      note.slice(0, startIdx) + '#' + tag + ' ' + note.slice(endIdx);
    setNote(newValue);
    const newPos = startIdx + tag.length + 2;

    inputRef.current.setSelectionRange(newPos, newPos);
    document.dispatchEvent(new Event('selectionchange'));
  }

  async function handleCreate(tag: string) {
    if (!inputRef.current) return;
    try {
      await send('tags-create', { tag });
      void refetch();
      handleSelect(tag);
    } catch (e) {
      dispatch(
        addNotification({
          notification: {
            type: 'error',
            message: 'Failed to add tag, check logs',
          },
        }),
      );
      console.trace(e);
    }
  }

  const hideScrollbar = css({
    'scrollbar-width': 'none',
    '-ms-overflow-style': 'none',
    '&::-webkit-scrollbar': {
      display: 'none',
    },
  });

  return (
    <View
      style={{
        width: '100%',
        padding: '4px 8px 4px 8px',
        borderRadius: 30,
        overflowX: 'auto',
        height: filteredTags.length || showNewTag ? 30 : 0,
        transitionProperty: 'height',
        transitionDuration: '100ms',
      }}
      className={hideScrollbar}
    >
      <View
        style={{
          display: 'flex',
          flexDirection: 'row',
          alignItems: 'end',
          flexWrap: 'nowrap',
          gap: 4,
          paddingRight: 8,
        }}
      >
        {filteredTags.map(tag => (
          <div key={tag.id}>
            <button
              type="button"
              style={{
                border: 'none',
                height: 22,
                maxWidth: '50dvw',
              }}
              className={getTagCSS(tag.tag)}
              onMouseDown={e => e.preventDefault()} // stops input from losing focus
              onClick={() => handleSelect(tag.tag)}
            >
              #{tag.tag}
            </button>
          </div>
        ))}
        {showNewTag && (
          <button
            type="button"
            style={{
              padding: '1px 1px 1px 9px',
              borderRadius: 12,
              borderWidth: 0,
              backgroundColor: theme.noticeBackground,
              color: theme.noticeTextDark,
              display: 'flex',
              alignItems: 'center',
              flexWrap: 'nowrap',
              gap: 4,
            }}
            onMouseDown={e => e.preventDefault()} // stops input from losing focus
            onClick={() => handleCreate(currentWordNoHash)}
          >
            <SvgAdd height={8} width={8} />
            <span style={{ whiteSpace: 'nowrap' }}>
              <Trans>Create tag</Trans>
            </span>
            <div
              style={{
                borderWidth: 0,
                height: 20,
                maxWidth: '50dvw',
                overflow: 'hidden',
                textOverflow: 'ellipsis',
                display: 'inline-block',
              }}
              className={getTagCSS('')}
            >
              #{currentWordNoHash}
            </div>
          </button>
        )}
      </View>
    </View>
  );
}

function isTemporary(transaction: TransactionEntity) {
  return transaction.id.indexOf('temp') === 0;
}

type TransactionEditUnconnectedProps = {
  categories: CategoryEntity[];
  accounts: AccountEntity[];
  payees: PayeeEntity[];
  lastTransaction: TransactionEntity | null;
  dateFormat: string;
};

function TransactionEditUnconnected({
  categories,
  accounts,
  payees,
  lastTransaction,
  dateFormat,
}: TransactionEditUnconnectedProps) {
  const { t } = useTranslation();
  const { transactionId } = useParams();
  const { state: locationState } = useLocation();
  const [searchParams] = useSearchParams();
  const dispatch = useDispatch();
  const updatePayeeLocationMutation = useSavePayeeLocationMutation();
  const navigate = useNavigate();
  const [transactions, setTransactions] = useState<TransactionEntity[]>([]);
  const [fetchedTransactions, setFetchedTransactions] = useState<
    TransactionEntity[]
  >([]);
  const isAdding = useRef(false);
  const isDeleted = useRef(false);

  const searchParamCategory = useMemo(
    () => categories.find(c => c.name === searchParams.get('category'))?.id,
    [categories, searchParams],
  );
  const searchParamAccount = useMemo(
    () => accounts.find(a => a.name === searchParams.get('account'))?.id,
    [accounts, searchParams],
  );
  const searchParamPayee = useMemo(
    () => payees.find(p => p.name === searchParams.get('payee'))?.id,
    [payees, searchParams],
  );

  const {
    isGranted: isLocationGranted,
    isPending: shouldPromptLocation,
    requestPermission,
  } = useLocationPermission();
  const { data: nearbyPayees = [] } = useNearbyPayees({
    enabled: isLocationGranted,
  });
  const [shouldShowSaveLocation, setShouldShowSaveLocation] = useState(false);
  const nearestPayee = nearbyPayees[0]?.payee ?? null;

  useEffect(() => {
    let unmounted = false;

    async function fetchTransaction() {
      // Query for the transaction based on the ID with grouped splits.
      //
      // This means if the transaction in question is a split transaction, its
      // subtransactions will be returned in the `substransactions` property on
      // the parent transaction.
      //
      // The edit item components expect to work with a flat array of
      // transactions when handling splits, so we call ungroupTransactions to
      // flatten parent and children into one array.
      const { data } = await aqlQuery(
        q('transactions')
          .filter({ id: transactionId })
          .select('*')
          .options({ splits: 'grouped' }),
      );

      if (!unmounted) {
        const fetchedTransactions = ungroupTransactions(data);
        setTransactions(fetchedTransactions);
        setFetchedTransactions(fetchedTransactions);
      }
    }
    if (transactionId !== 'new') {
      void fetchTransaction();
    } else {
      isAdding.current = true;
    }

    return () => {
      unmounted = true;
    };
  }, [transactionId]);

  useEffect(() => {
    if (!isLocationGranted) {
      setShouldShowSaveLocation(false);
    }
  }, [isLocationGranted]);

  useEffect(() => {
    if (isAdding.current) {
      setTransactions([
        {
          id: 'temp',
          date: (() => {
            const dateParam = searchParams.get('date') || '';
            if (!isNaN(Date.parse(dateParam))) {
              return dateParam;
            }
            return lastTransaction?.date || monthUtils.currentDay();
          })(),
          payee: searchParamPayee,
          account:
            searchParamAccount ||
            locationState?.accountId ||
            lastTransaction?.account ||
            null,
          category: searchParamCategory || locationState?.categoryId || null,
          amount: -amountToInteger(
            parseFloat(searchParams.get('amount') || '') || 0,
          ),
          cleared: searchParams.get('cleared') === 'true',
          notes: searchParams.get('notes') || '',
        },
      ]);
    }
  }, [
    locationState?.accountId,
    locationState?.categoryId,
    lastTransaction,
    searchParamAccount,
    searchParamCategory,
    searchParamPayee,
    searchParams,
  ]);

  const onUpdate = useCallback(
    async (
      serializedTransaction: TransactionEntity,
      updatedField: keyof TransactionEntity,
    ) => {
      const transaction = deserializeTransaction(
        serializedTransaction,
        null,
        dateFormat,
      );

      // Run the rules to auto-fill in any data. Right now we only do
      // this on new transactions because that's how desktop works.
      const newTransaction = { ...transaction };
      const changedFields = new Set<keyof TransactionEntity>([updatedField]);
      if (isTemporary(newTransaction)) {
        const afterRules = await send('rules-run', {
          transaction: newTransaction,
        });
        const diff = getChangedValues(newTransaction, afterRules);

        if (diff) {
          Object.keys(diff).forEach(key => {
            const field = key as keyof TransactionEntity;
            // Apply rule changes to "empty" fields and append/prepend notes rules
            // (see shouldApplyRuleChange).
            // Or update all fields if the payee changes (assists location-based entry by
            // applying rules to prefill category, notes, etc. based on the selected payee)
            if (
              updatedField === 'payee' ||
              shouldApplyRuleChange(field, newTransaction[field], diff[field])
            ) {
              (newTransaction as Record<string, unknown>)[field] = diff[field];
              changedFields.add(field);
            }
          });

          // When a rule updates a parent transaction, overwrite all changes to the current field in subtransactions.
          if (
            newTransaction.is_parent &&
            diff.subtransactions !== undefined &&
            updatedField !== null
          ) {
            newTransaction.subtransactions = diff.subtransactions.map(
              (st, idx) => ({
                ...(newTransaction.subtransactions?.[idx] || st),
                ...(st[updatedField] != null && {
                  [updatedField]: st[updatedField],
                }),
              }),
            );
            changedFields.add('subtransactions');
          }
        }
      }

      // Updates can be in flight at the same time (e.g. an amount blur
      // racing a nearby payee press), so merge only the changed fields onto
      // the latest state rather than replacing the whole transaction.
      setTransactions(prevTransactions => {
        const latestTransaction = prevTransactions.find(
          t => t.id === newTransaction.id,
        );
        if (!latestTransaction) {
          // The transaction was deleted while this update was in flight.
          return prevTransactions;
        }
        const merged = { ...latestTransaction };
        for (const field of changedFields) {
          (merged as Record<string, unknown>)[field] = newTransaction[field];
        }
        return updateTransaction(prevTransactions, merged).data;
      });

      if (updatedField === 'payee') {
        setShouldShowSaveLocation(false);

        if (newTransaction.payee && isLocationGranted) {
          const payeeLocations = await locationService.getPayeeLocations(
            newTransaction.payee,
          );
          if (payeeLocations.length === 0) {
            setShouldShowSaveLocation(true);
          } else {
            const currentPosition = await locationService.getCurrentPosition();
            const hasNearby = payeeLocations.some(
              loc =>
                calculateDistance(currentPosition, {
                  latitude: loc.latitude,
                  longitude: loc.longitude,
                }) <= DEFAULT_MAX_DISTANCE_METERS,
            );
            if (!hasNearby) {
              setShouldShowSaveLocation(true);
            }
          }
        }
      }
    },
    [dateFormat, isLocationGranted],
  );

  const onSave = useCallback(
    async (newTransactions: TransactionEntity[]) => {
      if (isDeleted.current) {
        return;
      }

      const changes = diffItems(fetchedTransactions || [], newTransactions);
      if (
        changes.added.length > 0 ||
        changes.updated.length > 0 ||
        changes.deleted.length
      ) {
        const _remoteUpdates = await send('transactions-batch-update', {
          added: changes.added,
          deleted: changes.deleted,
          updated: changes.updated,
        });

        // if (onTransactionsChange) {
        //   onTransactionsChange({
        //     ...changes,
        //     updated: changes.updated.concat(remoteUpdates),
        //   });
        // }
      }

      if (isAdding.current) {
        // The first one is always the "parent" and the only one we care
        // about
        dispatch(setLastTransaction({ transaction: newTransactions[0] }));
      }
    },
    [dispatch, fetchedTransactions],
  );

  const onDelete = useCallback(
    async (id: TransactionEntity['id']) => {
      const changes = deleteTransaction(transactions, id);

      if (isAdding.current) {
        // Adding a new transactions, this disables saving when the component unmounts
        isDeleted.current = true;
      } else {
        const _remoteUpdates = await send('transactions-batch-update', {
          deleted: changes.diff.deleted,
        });

        // if (onTransactionsChange) {
        //   onTransactionsChange({ ...changes, updated: remoteUpdates });
        // }
      }

      setTransactions(changes.data);
    },
    [transactions],
  );

  const onAddSplit = useCallback(
    (id: TransactionEntity['id']) => {
      const changes = addSplitTransaction(transactions, id);
      setTransactions(changes.data);
    },
    [transactions],
  );

  const onSplit = useCallback(
    (id: TransactionEntity['id']) => {
      const changes = splitTransaction(
        transactions,
        id,
        makeEmptySplitSubtransactions,
      );

      setTransactions(changes.data);
    },
    [transactions],
  );

  const onSaveLocation = useCallback(async () => {
    try {
      const [transaction] = transactions;
      if (transaction.payee) {
        await updatePayeeLocationMutation.mutateAsync(transaction.payee);
        setShouldShowSaveLocation(false);
      }
    } catch (error) {
      console.error('Failed to save location', { error });
      dispatch(
        addNotification({
          notification: {
            type: 'error',
            message: t('Failed to save location'),
          },
        }),
      );
    }
  }, [t, transactions, dispatch, updatePayeeLocationMutation]);

  const onSelectNearestPayee = useCallback(() => {
    const transaction = transactions[0];
    if (!nearestPayee || !transaction || transaction.payee) {
      return;
    }

    const updated = {
      ...serializeTransaction(transaction, dateFormat),
      payee: nearestPayee.id,
    };
    void onUpdate(updated, 'payee');
  }, [transactions, nearestPayee, onUpdate, dateFormat]);

  if (accounts.length === 0) {
    return (
      <Page
        header={
          <MobilePageHeader
            title={t('New Transaction')}
            leftContent={<MobileBackButton />}
          />
        }
        padding={0}
      >
        <View
          style={{
            flex: 1,
            alignItems: 'center',
            justifyContent: 'center',
            padding: 20,
            backgroundColor: theme.mobilePageBackground,
          }}
        >
          <Text
            style={{
              fontSize: 15,
              textAlign: 'center',
              marginBottom: 20,
              lineHeight: '1.5em',
            }}
          >
            <Trans>
              To add a transaction, you need to{' '}
              <strong>create an account first</strong>. You can add an account
              from the accounts page.
            </Trans>
          </Text>
          <Button
            variant="primary"
            onPress={() => {
              dispatch(
                pushModal({
                  modal: { name: 'add-account', options: {} },
                }),
              );
            }}
          >
            <Trans>Add account</Trans>
          </Button>
        </View>
      </Page>
    );
  }

  if (categories.length === 0) {
    return (
      <Page
        header={
          <MobilePageHeader
            title={t('New Transaction')}
            leftContent={<MobileBackButton />}
          />
        }
        padding={0}
      >
        <View
          style={{
            flex: 1,
            alignItems: 'center',
            justifyContent: 'center',
            padding: 20,
            backgroundColor: theme.mobilePageBackground,
          }}
        >
          <Text
            style={{
              fontSize: 15,
              textAlign: 'center',
              marginBottom: 20,
              lineHeight: '1.5em',
            }}
          >
            <Trans>
              To add a transaction, you need to{' '}
              <strong>create a category first</strong>. You can add categories
              from the budget page.
            </Trans>
          </Text>
          <Button
            variant="primary"
            onPress={() => {
              void navigate('/budget');
            }}
          >
            <Trans>Go to budget</Trans>
          </Button>
        </View>
      </Page>
    );
  }

  // This check ensures the component only renders after the transaction state
  // has been properly initialized. When creating a new transaction (transactionId === 'new'),
  // the transaction is created in a useEffect that runs after the component mounts.
  // Returning null here acts as a loading state until that initialization completes.
  if (transactions.length === 0) {
    return null;
  }

  return (
    <View
      style={{
        flex: 1,
        backgroundColor: theme.pageBackground,
      }}
    >
      <TransactionEditInner
        transactions={transactions}
        isAdding={isAdding.current}
        categories={categories}
        accounts={accounts}
        payees={payees}
        dateFormat={dateFormat}
        onUpdate={onUpdate}
        onSave={onSave}
        onDelete={onDelete}
        onSplit={onSplit}
        onAddSplit={onAddSplit}
        shouldShowSaveLocation={shouldShowSaveLocation}
        onSaveLocation={onSaveLocation}
        onSelectNearestPayee={onSelectNearestPayee}
        nearestPayee={isLocationGranted ? nearestPayee : null}
        onRequestLocation={shouldPromptLocation ? requestPermission : undefined}
      />
    </View>
  );
}

type TransactionEditProps = Omit<
  TransactionEditUnconnectedProps,
  'categories' | 'accounts' | 'payees' | 'lastTransaction' | 'dateFormat'
>;

export const TransactionEdit = (props: TransactionEditProps) => {
  const { data: { list: categories } = { list: [] } } = useCategories();
  const { data: payees = [] } = usePayees();
  const lastTransaction = useSelector(
    state => state.transactions.lastTransaction,
  );
  const { data: accounts = [] } = useAccounts();
  const dateFormat = useDateFormat() || 'MM/dd/yyyy';

  return (
    <SingleActiveEditFormProvider formName="mobile-transaction">
      <TransactionEditUnconnected
        {...props}
        categories={categories}
        payees={payees}
        lastTransaction={lastTransaction}
        accounts={accounts}
        dateFormat={dateFormat}
      />
    </SingleActiveEditFormProvider>
  );
};

function FillRemainingButton({
  remaining,
  onPress,
}: {
  readonly remaining: number;
  readonly onPress: () => void;
}) {
  return (
    <Button
      variant="primary"
      style={{ height: styles.mobileMinHeight }}
      onPress={onPress}
      isDisabled={remaining === 0}
    >
      <SvgSplit width={17} height={17} />
      <Text
        style={{
          ...styles.text,
          marginLeft: 6,
        }}
      >
        <Trans>
          Use remaining:{' '}
          {{
            amount: integerToCurrency(Math.abs(remaining)),
          }}
        </Trans>
      </Text>
    </Button>
  );
}
